SvelteKit/SSGでビルド時にsitemap.xmlを自動生成する方法

# はじめに
弊社ではコーポレートサイトなどのWebサイトの制作において、既存のWordPressによる構築から以下のようなJAMstackでの構築に移行しています。
- 静的サイトジェネレーター(SSG)
[SvelteKit(adapter-static)](https://kit.svelte.jp/docs/adapter-static)
- ヘッドレスCMS
[Newt](https://www.newt.so/)
- ホスティングサービス
[Cloudflare Pages](https://www.cloudflare.com/ja-jp/developer-platform/products/pages/)

WordPressからJAMstackへの移行に際しては、プラグインで解決できていた部分をアプリケーションの実装側で解決しなければいけない点がいくつかありますが、今回はSvelteKit(adapter-static)でsitemap.xmlをビルド時に簡便に自動生成する方法を紹介したいと思います。

# アプローチと使用するライブラリ
Next.jsなどでは既に外部ライブラリや公式ライブラリとしてsitemapを自動生成するライブラリが存在し、QiitaやZennで記事も多数あるが、SvelteKitには公式ライブラリやデファクトスタンダートとなるようなライブラリは見当たらなかった。

各ページへの追加の実装を伴わずにビルド時に自動生成したかったので、アプローチとしては[sitemap.js](https://github.com/ekalinin/sitemap.js)を利用して1スクリプトで完結させる。

また、sitemap.jsで生成されるsitemap.xmlをフォーマットするために、[prettier](https://github.com/prettier/prettier) + [@prettier/plugin-xml](https://github.com/prettier/plugin-xml)を利用する。

# 生成すべきパスの取得
adapter-staticのデフォルト設定でビルドした場合、build/には以下のようなディレクトリ構成でHTMLファイルが生成される。
```
{app}/
└── build/
    ├── contents/
    │   └── {id}.html
    ├── index.html
    └── contents.html
```
※/routes/contents/[id]というような一覧/詳細画面構成をルーティングしていた場合

シンプルに拡張子が.htmlのファイルをsitemapに記載すべきページと捉えれば、build/に存在する.htmlファイルを再帰的に探索することで対象となるパスの配列を取得することができる。
```js
import { readdirSync, statSync } from 'fs'
import { resolve } from 'path'

const getStaticPaths = (dir = 'build') => {
  const results = []
  readdirSync(dir).forEach((file) => {
    const filePath = resolve(dir, file)
    const stat = statSync(filePath)
    if (stat && stat.isDirectory()) {
      results.push(...getStaticPaths(filePath))
    } else if (file.endsWith('.html')) {
      if (file === 'index.html') {
        /**
         * index.htmlはsitemapの先頭に記載したいのでunshift
         * さらにトップページ(/)として記載したいのでファイル名を空文字としておく
         */
        results.unshift(
          filePath.replace(resolve('build'), '').replace('index.html', '')
        )
      } else {
        // sitemap.xmlには.htmlは記載しないのでファイル名から除外しておく
        results.push(
          filePath.replace(resolve('build'), '').replace('.html', '')
        )
      }
    }
  })
  return results
}
```

また、ページは生成するがsitemapには記載したくないというケースも往々にして存在するので除外リストも作成し先ほどの関数で取得した配列をフィルターするのも良いかと。
```js
const IGNORE_PAGES = ['/404', '/error', 'privates']
const paths = getStaticPaths().filter((path) => !IGNORE_PAGES.includes(path))
```

# sitemap.xmlの生成
ここまでで生成すべきpathは特定できているので、あとはsitemap.jsを利用し/buildにsitemap.xmlを生成する。
```js
import { createWriteStream } from 'fs'
import { resolve } from 'path'

import { SitemapStream, streamToPromise } from 'sitemap'

const BASE_URL = 'https://example.com'

const generateSitemap = async () => {
  const paths = getStaticPaths().filter((path) => !IGNORE_PAGES.includes(path))
  const sitemap = new SitemapStream({
    hostname: BASE_URL
  })
  const writeStream = createWriteStream(resolve('build', 'sitemap.xml'))

  sitemap.pipe(writeStream)

  paths.forEach((path) => {
    sitemap.write({
      url: path
    })
  })

  sitemap.end()

  await streamToPromise(sitemap)
  // 後述するフォーマット処理のためにwriteStreamの書き込み終了を待ってPromiseを解決
  await new Promise((resolve) => writeStream.on('finish', resolve))
}
```

# sitemap.xmlのフォーマット
sitemap.jsで生成したsitemap.xmlは1行にminifyされている。
データ転送量を考えればこのままでも良いが、見やすくしておきたい場合もあると思うので、prettier + @prettier/plugin-xmlでフォーマットする。
```js
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'

import prettierPluginXML from '@prettier/plugin-xml'
import prettier from 'prettier'

const formatSitemap = async () => {
  const sitemapPath = resolve('build', 'sitemap.xml')
  const sitemapContent = readFileSync(sitemapPath, 'utf-8')
  const formattedSitemap = await prettier.format(sitemapContent, {
    parser: 'xml',
    plugins: [prettierPluginXML],
    xmlWhitespaceSensitivity: 'ignore'
  })
  writeFileSync(sitemapPath, formattedSitemap)
}
```

# 最終的なスクリプト
実行用のmain関数を定義しgenerateSitemap -> formatSitemapの順に同期実行する。
```generate-sitemap.js
import { createWriteStream, readFileSync, writeFileSync } from 'fs'
import { readdirSync, statSync } from 'fs'
import { resolve } from 'path'

import prettierPluginXML from '@prettier/plugin-xml'
import prettier from 'prettier'
import { SitemapStream, streamToPromise } from 'sitemap'

const BASE_URL = 'https://example.com'
const IGNORE_PAGES = ['/404', '/error', 'privates']

const getStaticPaths = (dir = 'build') => {
  const results = []
  readdirSync(dir).forEach((file) => {
    const filePath = resolve(dir, file)
    const stat = statSync(filePath)
    if (stat && stat.isDirectory()) {
      results.push(...getStaticPaths(filePath))
    } else if (file.endsWith('.html')) {
      if (file === 'index.html') {
        results.unshift(
          filePath.replace(resolve('build'), '').replace('index.html', '')
        )
      } else {
        results.push(
          filePath.replace(resolve('build'), '').replace('.html', '')
        )
      }
    }
  })
  return results
}

const generateSitemap = async () => {
  const paths = getStaticPaths().filter((path) => !IGNORE_PAGES.includes(path))
  const sitemap = new SitemapStream({
    hostname: BASE_URL
  })
  const writeStream = createWriteStream(resolve('build', 'sitemap.xml'))

  sitemap.pipe(writeStream)

  paths.forEach((path) => {
    sitemap.write({
      url: path
    })
  })

  sitemap.end()

  await streamToPromise(sitemap)
  await new Promise((resolve) => writeStream.on('finish', resolve))
}

const formatSitemap = async () => {
  const sitemapPath = resolve('build', 'sitemap.xml')
  const sitemapContent = readFileSync(sitemapPath, 'utf-8')
  const formattedSitemap = await prettier.format(sitemapContent, {
    parser: 'xml',
    plugins: [prettierPluginXML],
    xmlWhitespaceSensitivity: 'ignore'
  })
  writeFileSync(sitemapPath, formattedSitemap)
}

const main = async () => {
  await generateSitemap()
  await formatSitemap()
}

main()
```

また、ビルドフェーズに組み込みたいので、ビルド時に実行される処理を`vite build` => `node generate-sitemap.js`となるようにしておく。
ビルド用のシェルスクリプトなどがある場合は以下のようなイメージ。
```bash
#!/bin/bash

set -e

vite build
node generate-sitemap.js
```

# おまけ
sitemap.xmlとあわせrobots.txtも生成したい場合はこのような関数で簡単に生成できます。
```js
const generateRobotsTxt = () => {
  const disallowRules = IGNORE_PAGES.map((path) => `Disallow: ${path}`).join('\n')
  const content = `
User-agent: *
${disallowRules}
Sitemap: ${BASE_URL}/sitemap.xml
  `.trim()

  writeFileSync(resolve('build', 'robots.txt'), content)
}

const main = async () => {
  await generateSitemap()
  await formatSitemap()
  generateRobotsTxt()
}

main()
```

# まとめ
今回紹介した方法であれば既存のページの実装に手を加えることなく、汎用的にsitemap.xmlを生成できるかと。
ただ、記事中で紹介している内容では最低限必要なページをsitemapに網羅できていますが、`lastmod` `changefreq` `priority`といった各オプションに適切な値を設定するには別途仕組みを考える必要があるのでご注意ください。

# 参考
https://github.com/ekalinin/sitemap.js

https://github.com/prettier/prettier

https://github.com/prettier/plugin-xml