[Nuxt3 SSG] nuxt/contentを使ってソシャゲお知らせページを作った

約1年程前に終了したとあるソシャゲの、親の顔より見たニュースページが恋しくなり、もう一度見たくなりました。そこで、Nuxt3のSSGモードで、Markdownベースに作り直してみました。

# 利用したもの
- 言語
  - [TypeScript](https://www.typescriptlang.org/)
  - JavaScript (ES2022)
- ビルド環境
  - [Node 20](https://nodejs.org)
  - [pnpm](https://pnpm.io/ja/)
- フレームワーク
  - [Nuxt3 (SSGモード)](https://nuxt.com/)
  - [Vue3.4](https://ja.vuejs.org/)
- ライブラリ
  - [Vuetify3 (ベースUI)](https://vuetifyjs.com)
    - [vuetify-loader(ツリーシェイク)](https://vuetifyjs.com/en/features/treeshaking/)
    - mdi/js (アイコン削減)
    - sass (スタイル削減)
  - [@nuxtjs/i18n (多言語対応)](https://i18n.nuxtjs.org/)
  - [nuxt/content v2 (Markdownからのページ生成)](https://content.nuxt.com/)
  - [nuxt/image v1.3.0 (画像の自動圧縮)](https://image.nuxt.com/)
- リンター・フォーマッター
  - ESLint
  - Prettier
- デプロイ
  - Github Actions
  - Github Pages
- 記事
  - 画像: StableDiffusion
  - 文章: ChatGPT
- 時間
  - 2週間 (Wakatime記録で約30時間)

# 出来上がったもの

## サイト
https://dosugamea.github.io/recreate-news/

- ニュース一覧画面
![7b7a1a1d63bc-20240120.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/4c24b84c-6ecf-baa8-d88e-60114d53f021.png)

- ニュース詳細画面
![d29a28e27b95-20240120.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/bb1dbfd8-aca1-dab1-554b-065d7e810b1e.png)
![4161652c6494-20240120.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/a0e900ed-3ea0-0232-54dd-889e52599431.png)

- Lighthouseスコア (モバイル)
![ef72c2b8fe07-20240120.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/eb501ded-e93b-87e5-cf9d-433b727b00f6.png)

## ソースコード

https://github.com/Dosugamea/recreate-news

AGPLv3にしてますが、ビルド設定とか表に見えない範囲はご自由に再利用ください。

### 試作コンポーネント集
サイトを作り始める前に一部コンポーネントをVuetify Playgroundで作成しました。
設定不要・登録不要でかなり便利なので、Vuetifyご利用の方にはおすすめです。

- [お知らせTIPSのコンポーネント](https://play.vuetifyjs.com/#eNqNUDtSwzAUvIpHdWTNEBoyJhPOgSgU60EEkqyRZPPJpAgdXIGCipOl4Bo8KXbGJE1mXHj3vd3Vvts1Cb5mN86VXQtkRqoIxmkRYc5tUVQdFc7l3wzqxkahLPie2pMr5YpaixCuOXEvdFosH6hT9omTIqg3QNaAVK1B7JTWRSe8EjYi37RRo53k5GCYLRUGDdpghNYoHRKMpxcI00pCUuGrjAEbqdDgI47Y2Cw4Yee77efv98/u/WO3/apYpg4F2L7BUDLBfy0Tsz9CxUbHIROijGt8pEa48jE0Fq+3TgLeDwInsyIzicPzJszJKkYXZozV0qJMgladLy1EZp1hC1xjvrVRGaCyMYtpOS2vmFQhjukSgqFL3zwH8GjCyWQUE9X96xlR/SYmXA4JPUW1WIYUcWLNMK8DTz1YCR78uY2OZONWR6OTZil9wy1+ZDMhWYinz48md39Zseym
)
- [NEW表記をボタンに付けてみるテスト(レスポンシブ化時上手く行かず没)](
https://play.vuetifyjs.com/#eNp1kT1PwzAQhv+K6xnXiQIDyERiYGVlIAxOcomqmnNkX0MqxH/n6qQBVNWL7+PR+b3Xb18yhkY/DcN2PIB8kIbgY3CWoKxQCDMq8t7VNojGOx8eK1m7A6g+wFG1NuwBVVHJxCY6DraBUBq9hhWuzZpQNM7GyGMIJlLoESopItlmD+0yZUZt24PwXReB1MR8XmRMLoUjF1TOD7MqJEDi/OX5dbNJlVnnpw24w34Vlwa3u3FV0KrOwfSvzwSrxjLLdXZndIovu3Y5F4DRPP93C/YgrXF255TTmb/i1YwtnnNu9J//kN83stgW23t5um/5fv8BErmIgQ==)
- [キャラクター情報コンポーネント](https://play.vuetifyjs.com/#eNrtV01rE0EY/ivjXqLgZJM2taWkAW89evNgPGx3J5upsx/sTtLUUsg26qFaqYh6E8ViC0VRUCj48WOcJr/Dd3aTdLOZTS0U8WAgsDvzvN/P+87OnS0tDEz9pu8X2y2iLWtVThyfGZzU6i5C1TY2fD9+jF9Mz+UGdUkwXIoXA28DrbdCThubK3XNJC4nQV1DBqO2G0vASmpjLDrUyBD8QwCU50CK2fC0NIFKcEZgIZMZoUT6Bq4AtgF+TuAAadH2GGbhBiOdkSOx+ZGjmLhWxsjQEAWPs8sItXHDC0AlRdRFC3VtGuG1OIPMWNM7EF4s6wWGaxOVbEjvEwB0MDMCNUJ6BQjHojjkBmRxGkIYaRucxjjIThag5wU79m+NtcD2lDNZ29ikgcnkRr7KWToScRwyahJcVqsJfcOtie6+iH6K6JuIHonoQESPRfRAdJ9V9Xg7U3gdKp9dS7g5ooPDpTmVzykSQm2nEElcjo2gVQDS5NwPl3Ud2sQkTY9ZxXVfn79R6lSWSkXftdUh6bGZc6wvguzIXbUj/nifkw7HzQpqQIfhDULtJscOsWjLAUmxcyx6r0QvEr3vove0qo+aOGMbkkYtaItxjjZzSjLOputhu8WhlUIFZhzPRL/ckFElAQIxlWJ/0LmOoSxf2nCTnqVn1OhD6Q28UEJOgOcm8rUG1VP10uhXW70VFw4U54HyHYpZWp4rVZSEnUXc2ZRJdqAWs/kBdK+gmCTDFKSzmZPITP+GjsFYUry4mj51700PhBy+nKttNA8vS5+5abgX0SaLMs7WNCtqIjoS0TsRvRfRroj2ZlTxkh37u2m7UFFzBomid2GSwHmudigNl2BsA5tndHcyfvMazTE6uBlXTs7vkt/J7+gZA3yx1Fkcjm+1dE5y89v3wlGmepfFyfv18g0a7Hb7+0A+Vd4TKTgonwxefO0/PDr98Vx090R0PPj0pf/59aB7KKKP/YND0euK3lt5FOyc5GnKCyR3/ezwUBJFRj9xCsivu/8c+Tc5cnry4fI5oj6/VGdXjIQP/PTVICM8KZbspu8iciW5qlT11BUGXkMzoD5HIeGtJCrq+F7A0RYKSANto0bgOagAt5+ChMvPdTfkyAlttCIRVwurhDEP3fYCZl0pXJMGEpWgTNu+rs0XK8XyvCYfFool7e5v9gW5SA==)

### お知らせ記事の記入例
![73198244335a-20240125.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/aff6d2fe-5167-f5ab-b6f7-a856848e19d4.png)

# 工夫したところ

## i18n対応
右下のボタンから言語を選択するか、URLを直指定で言語毎に違うMarkdownを表示できるようにしました。

## レイアウトの再現をしつつレスポンシブ対応化
詳細画面のキャラクター紹介レイアウトをかなり凝って作りました。なおかつ、各種文字列や幅がレスポンシブになっています。

元ネタとしたソシャゲのページでは、デザインの保持を優先したのか、画面幅を元に訪問時一度だけ`transform: scale(1.x)`をHTML要素全体にふりかけていましたが、こちらはちゃんとレスポンシブしています。

## LightHouseのスコア上位を目指した
出来上がった直後のスコアを測ってみると、パフォーマンススコアとアクセシビリティスコアが80ぐらいと微妙なことになっていました。気になったので可能な限り修正しました。

### アクセシビリティスコア改善
- [`nuxt/i18n`の推奨設定](https://i18n.nuxtjs.org/guide/seo)に従い html属性に lang="ja" または lang="en"が付くように
- バッジなどのコントラスト比が `4.5:1`を満たしていなかったので修正
- 画像にalt設定

### パフォーマンススコア改善
- VuetifyのSASSカスタマイズで`color-pack`をやめて 使うものだけ手動でスタイル定義。
- `nuxt/image`を使って画像を事前圧縮

# 開発で詰まったところ
当たり前のことも含みますが初めてのSSGだったので困ったこと等をまとめます。

**※ 下記は今回こうやって回避したという情報であり正しいやり方とは思えないのでご注意ください**

## プロジェクトの設定

### ESLint/Prettierのルール設定がよくわからない
毎回初期化時に困っています、個人的に最初の難関です。手動で色々指定しようとしましたが、よく分からなかったので Nuxtの公式(と思われる) `@nuxtjs/eslint-config-typescript` をextendsしてお茶を濁しました。

#### VSCodeでPrettierが効かない
いざESLintとPrettierを設定し、VSCodeで保存したところ、何度やってもprettierが効きませんでした。数時間悩みましたが、単に `{ "editor.defaultFormatter": "esbenp.prettier-vscode" }` が足りていないだけでした... 今回使ったVSCodeの設定はworkspaceとしてまとめて上げてありますので、困っている方はそれが参考になるかもしれません。

## SSGの設定

### SPAとしてビルドしようとするとサーバーAPIも付いてくる
nuxt/contentを使ったプロジェクトをSPAとしてビルドしようとすると、サーバーが一緒に生成されます。よく考えるとわかるのですが、nuxt/contentはAPIサーバーをNuxt3に生やすライブラリのため、SPAにしたいと思っても別途サーバーが必要になる仕様です。つまり、SPA(サイトだけ静的ファイル、Contentは別途サーバーをホストする) /　SSG(サイトもContentも静的ファイル) / SSR(サイトとContent両方を1サーバーとしてホストする)の3択になります。サーバーを建てたくない場合、HydrationMismatchからは逃げられません。

### 動的に読み込むページがSSGで動かない
NuxtのSSGは通常、ページルーティングから検出されたURLを元に、初回描画状態をSSGします。これは例えば `index.vue` と `about.vue`、`[id].vue`があれば、その3ファイルのページ読み込み時の状態だけをSSG結果に含みます。

ここで何に困ったかというと、`index.vue`内でボタンを押したら動的に次ページを読み込む処理ができませんでした。対応するデータが事前レンダリングされていないため、クリックするとfetchに相当する処理で404が吐かれてしまいます。予め `/pages/[id].vue` のようなURLを決めてページネーションを置くか、なんちゃって動的ロード (一覧データ自体は最初に全てfetchしてそのうちのn番目までだけを描画する)か、サーバーでのみクライアントから呼び出される予定のAPIを先に読みだす変則的な処理(?)の3択になります。今回は見た目を素早く再現することを優先したかったため、なんちゃって動的ロードとしました。

https://github.com/Dosugamea/recreate-news/blob/main/src/components/NewsList.vue

### nuxt/contentで書いたページの生成結果がNotFoundになる
デフォルトだとなぜか`nuxt/content`を利用しているページの一部がNotFoundになってしまいます。原因は動的ルーティングの一部がPre-renderの自動クロールに引っかからないことでした。[Nuxt3のSSGでNuxt Contentを使用する](https://zenn.dev/sn10/articles/6228e1a472c094
)という記事に記載の処理を少しだけ弄って明示的にページとして認識すると解決しました。ここで指定するアドレスはMarkdownファイルのパスではなく、生成結果、実際にユーザーがアクセスする際のパスなので要注意です。

https://github.com/Dosugamea/recreate-news/blob/a39efae638e8c4c74dfbfc63c650b1cfc588b51d/nuxt.config.ts#L4-L31

## nuxt/i18nとの組み合わせ

### nuxt/i18nのパスとnuxt/contentのパスが上手く噛み合わせられない
nuxt/i18nには、自動的に言語個別のルートを生成するルーティング機能があります。これを言語別にフォルダを区切ったnuxt/contentページと組み合わせる場合、[ルーティングストラテジー](https://i18n.nuxtjs.org/guide/routing-strategies#strategies)は`prefix`にする必要がありました。

#### ストラテジー比較
- `prefix` (全ロケールのパスにprefix)
- `prefix_except_default` (デフォルトロケール以外のパスだけprefix)
- `prefix_and_default` (全ロケールのパスprefix + パスにロケールがなければデフォルトロケール)
  - この2つのストラテジーはSSGでデフォルトロケールがどれに当たるかをnuxt/contentに伝える上手いやり方が見当たらず失敗
- `no-prefix` (prefixを一切使わない)
  - このストラテジーの場合nuxt/contentを使ったページだけ `ja`や`en`が付き不自然なURLになる

### nuxt/i18nで書いたページの生成結果がNotFoundになる
nuxt/contentと同様の原因で、`/ja` や `/en`のケースも想定しないと、SSG結果にNotFoundが発生することがあります、パスを手動で指定しましょう。

なお、indexページだけは`prefix`戦略では `/en`等にリダイレクトされてしまうことになり、SSGではリダイレクトを処理してくれずエラーになります。そこでIndexページだけi18nパス設定を無効にしました。

https://github.com/Dosugamea/recreate-news/blob/a39efae638e8c4c74dfbfc63c650b1cfc588b51d/src/pages/index.vue#L1-L3

#### ロケール変更時nuxt/i18nのロケールとnuxt/contentのURLを同期する
nuxt/contentの公式サイトに記載がありそうなユースケースですが、見つけられずかなり難航しました。最終的にかなり荒業ですが、nuxt/i18nのロケール変更時、Cookieへ変更先の言語を代入し、ルーター上の言語に相当する文字列を置き換えて別のnuxt/contentのページに遷移させることで解決させました。

https://github.com/Dosugamea/recreate-news/blob/a39efae638e8c4c74dfbfc63c650b1cfc588b51d/src/components/TheLanguageSwitcher.vue#L4-L17


## nuxt/contentの利用

### 自作コンポーネントをMarkdown内で使う方法がわからない
nuxt/contentのドキュメントをしっかり読めばちゃんと記載があるのですが、すぐには見つからず困りました。下記に対応するリンクを記載します。

- [自作したコンポーネントのslotを使う例 (Card.vue)](https://content.nuxt.com/usage/markdown#block-components)
- [自作したコンポーネントのpropを使う例 (Alert.vue)](https://content.nuxt.com/usage/markdown#props)

### Markdown内の改行が反映されない

#### 改行を改行として認識してもらう
そもそもnuxt/contentでは 1行の改行を改行として認識してくれません。
そのため初めは `<br>`を大量に書いて誤魔化していました。
この仕様は[remark-breaks](https://blog.ksoichiro.com/ja/post/2021/03/migrate-blog-to-nuxt-content3/#markdown-%E3%81%AE%E6%94%B9%E8%A1%8C%E3%81%A7-html-%E3%81%AE%E6%94%B9%E8%A1%8C%E3%81%8C%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%8B)を使うと回避できます。

#### マージンを整えるパーツを作成する
しかし、remark-breaksを使ったとしても、2行連続する改行は1行の改行にまとめられてしまうというのが仕様になっています。
[先駆者さんはNitroPluginを書いて回避していました](https://qiita.com/prhm0998/items/427416dc7acf4b9c06f4)。が、今回はVuetify3を使っていることから`<br>`を連打するより、高さ付きの透明なv-dividerを使うと、実装シンプルで汎用性が高いと感じたのでv-dividerをラップするようなコンポーネントを作成しました。

https://github.com/Dosugamea/recreate-news/blob/main/src/components/content/SparkleDivider.vue

### ホットリロードすると 数バージョン前の結果が表示される
Composableを使って処理を共通化しているためか、ホットリロードすると古いMarkdownを元にしたページがレンダリングされてしまいました。フルリロードするか、ビルドすると正しい結果が描画されたため、ここは耐えて妥協しました...

## nuxt/imageの利用

### バージョンがよくわからない
Nuxt3用は v1系 (現時点最新は`@v1.3.0`)、Nuxt2用は v0系です。何も指定せずに単に `pnpm install nuxt/image` とすると なぜか v0系(`@v0.7.X`)がインストールされますが、それはNuxt2用ですので、バージョンをよく確認しましょう。

### Vuetifyと組み合わせる方法がよくわからない

[公式サイトにピッタリな例](https://image.nuxt.com/usage/use-image#:~:text=Use%20a%20preset-,Example,-%3A%20Responsive%20srcset)があったため、それをほぼ再利用し、ProseImg.vueとして nuxt/contentのデフォルトコンポーネントを上書きしました。

https://github.com/Dosugamea/recreate-news/blob/main/src/components/content/ProseImg.vue

## GithubPagesへのデプロイ

### GithubPagesのビルド結果がおかしい

#### ビルド結果にWelcome to nuxt! が出てくる
ちゃんとcheckoutして pnpm installも通っているのになぜ?? と頭を抱えざるを得ませんでした... 原因はGithubActionsが自動検出して推奨してきた`actions/configure-pages@v4`が Nuxt2想定(nuxt.config`.js`)のアクションで、Nuxt3 (nuxt.config`.ts`)を検出してくれず、新規ファイルを作成されるためでした。結局 `actions/configure-pages@v4`の利用は諦めて、手動で代替となるGithubPages用のサブパス対応処理を nuxt.config.tsに設定しました。

https://github.com/Dosugamea/recreate-news/blob/a39efae638e8c4c74dfbfc63c650b1cfc588b51d/nuxt.config.ts#L82-L87

#### ビルドしたページの画像が出ない
nuxt/contentの画像パスは cdnUrlを尊重せず、記載された通りのパスを忠実に描画しようとします。[Issue](https://github.com/nuxt/content/issues/106)によると、[nuxt-content-assets](https://github.com/davestewart/nuxt-content-assets)というライブラリを使うと、markdownと同一パスにある画像ファイルを解決可能になりますが、自環境ではProseImg.vueで上手くレンダリングが行えませんでした。

苦肉の策として、ランタイム設定のbaseURLを読み出して何もなければlocalhost、それ以外ならGithubのURLを返すようなcomposableを作り、それを画像URLに返すことで回避しました。
https://github.com/Dosugamea/recreate-news/blob/main/src/composables/useImageLink.ts


# 完走した感想

## Wordpressをカスタマイズした方が早そう
内容がかなりシンプルなので、プロジェクトを作らずともWordpressのスタイルをカスタマイズしたほうが再現という点では早そうでした。ですが、どうしてもサーバーを保守したくないのでやはりSSGにするしかなかったかなとも思います。

## SSGが難しい
Nuxt3の開発体験はまぁまぁ良いですが、nuxt/content、nuxt/i18nを使ったSSGはイマイチでした。もっといいやり方もありそうな気がしますがあまり資料が見つけられず微妙な実装に... 最後に入れた nuxt/imageについてはそれほど躓くことなく、すんなりSSGすることができました。

## デプロイが難しい
サブパスを使うデプロイをしようとしたから、というのがあるのですが、丸2日程引っかかりました。正直もう二度とやりたくないので、次からは手持ちの適当なドメインを持ち込んでデプロイします。

## 今後できればしたいこと
ぱっと見は悪くないですが、ところどころ微妙です。
下記は時間をかければ直せそうな気もしますが疲れ尽きたので一旦これまでにします...

- なぜか `/`のリダイレクトがかかる
- ファビコンが機能してない
- 文字サイズが結構ぶれてる
- アイコンの色が揃ってない
- HydrationMismatchを修正する
- ベターなnuxt/contentとnuxt/i18nの言語設定連携
  - https://github.com/nuxt/content/issues/642#issuecomment-824152641
  - https://github.com/nuxt/content/issues/1908