🔨

SvelteKit + Newt + Github Pageでブログをデプロイするまで

2022/04/21に公開

前提

今回、SvelteKitの勉強も踏まえてSvelteKit + NEWT + Github Pagesで簡単なブログを作ってみました。SvelteKitはNext.jsなどと比べて、まだ情報量が少なく、躓く点がいくつかあったため、ここに残そうと思います。

構成

使用した技術

  • SvelteKit : フロントエンドフレームワーク。今回は基本的に全てのページをPrerender(ビルド時に生成)する
  • Github Pages:デプロイ先として使用
  • Newt:Headless CMSとして記事の管理に利用

ファイル構成

概ねSvelteKitのテンプレートに沿った構成となっており、再利用するようなコンポーネントやライブラリはlibフォルダから参照する形になっています(あまり大きなコードではないので、libの中にまとめています)

src
 |- lib
     ---コンポーネント---
     |- header
     |- footer
     ---クライアントライブラリ---
     |- newt
     ---型定義---
     | -types
 |- routes
     |- articles
         |- [slug].svelte
	 |- [slug].ts
     |-pages
         |- [...page].svelte
	 |- [...page].ts
     ...

実装のポイント

ソースは以下にまとめているので、ここでは簡単にポイントをピックアップしながら記載していきたいと思います(Svelteの記法や動作確認方法などはSvelte公式等を参照のこと)。
https://github.com/mktu/svelte-simple-blog

1.NewtのAppセットアップと記事作成まで

以下のクイックスタートの内容に従い、Appモデルを作成し、CDN API Tokenを発行します。非常にシンプルでテンプレートもしっかりと用意されているので、概ね迷うことなく進められるかと思います。
https://www.newt.so/docs/quick-start
基本的に記事は対応するモデル(=Article)に定義したフィールド(タイトル・本文・作成日時...etc)に従ってNewt側で編集・管理し、これをJavaScript SDKで取得する形となります。

2.SvelteKit 静的サイト生成の設定

SvelteKit + Github Pageの構成では、ビルド時に全てのページをレンダリングします。そのため、ビルド時にhtmlを生成するためのPrerenderの設定、およびadapter-staticのインストール・設定を行います。

  • adapter-staticのインストール
npm i -D @sveltejs/adapter-static
  • svelte.config.jsの設定
svelte.config.js
import static_adapter from '@sveltejs/adapter-static';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	...
	kit: {
		adapter: static_adapter(),
		...
		prerender : {
			default : true // デフォルトで全てのページがprerenderされる
		}
	}
};

3.SvelteKit + Newtのページ生成

ブログページをビルドする際にNewtから必要なデータ(記事のタイトル、本文...)を取得し、レンダリングを行います。
SvelteKitではデータを取ってくる側(Endpoint)とレンダリングする側(Page)をそれぞれ実装します。※コードは一部かつ、簡略化したものとなります

Endpointの実装

Newtから記事を取得してPage側に渡します。SvelteKitではファイル名([slug].tsにおけるslug)に対応したパラメータがparams内に設定されますので、ここからNewtにリクエストする為のパラメータを取り出します。

[slug].ts
import { getArticle } from '$lib/newt/client'
import type { RequestHandler } from '@sveltejs/kit';

export const get: RequestHandler = async ({ params }) => {
	const article = await getArticle(params.slug)
	return {
	    body : {
		article
	    }
	};
};
lib/newt/client.ts
import { createClient } from 'newt-client-js'
import type { Auther, Article, } from '$lib/types'
// UIDとTOKENは環境変数より取得
const client = createClient({
    spaceUid: import.meta.env.VITE_SPACE_UID as string, 
    token: import.meta.env.VITE_CDN_API_TOKEN as string,
    apiType: 'cdn' // You can specify "cdn" or "api".
});

const AppId = 'blog'
const ArticleId = 'article'

...
// Newtの記事を取得するメソッド
export const getArticle = async (slug:string)=> {
    // Newtから記事データを取得する
    const article = await client
        .getContent<Article>({
            appUid: AppId,
            modelUid: ArticleId,
            contentId: slug
        })
    return article
}

Newtの記事を取得するgetArticleにおいては、スペースUIDやtokenが必要になります。開発環境では.envなどの環境変数に埋め込み、Github ActionsでビルドするにあたってはSecretsを用います(詳細は後述)。SvelteKitでは内部でViteを用いているため、環境変数にアクセスするにはimport.meta.env.VITE_SPACE_UIDといったようにViteの規則に従う必要があります(参考:
SvelteKit FAQ
)。

Pageの実装

Endpointから受け取った記事データを用いてレンダリングします。Newtでは、記事の本文がリッチテキストもしくはMarkdownで書かれている場合、htmlデータとして取得することができるので、{@html article.body}といった形でデータを渡してやります。

[slug].svelte
<script lang="ts">
	import type { Article } from '$lib/types';
	export let article: Article; // Endpointで設定されたBodyからデータを受け取る
</script>

<svelte:head>
	<title>{article ? article.title : '記事が存在しません'}</title>
</svelte:head>

<div class="container">
	{#if article}
		<article>
			<header>
				<h1 bind:this={element}>{article.title}</h1>
				...
			</header>
			<div class="content">
		<!-- 記事本文のレンダリング -->
                {@html article.body}
			</div>
			<footer>
				<div class="meta">{format(new Date(article._sys.updatedAt), 'yyyy-MM-dd')}(更新)</div>
			</footer>
		</article>
	{:else}
		<h1>記事が見つかりませんでした</h1>
	{/if}
</div>
<style lang="scss">
	...
</style>

ここでは割愛していますが、同じ要領で、他の一覧ページなども作成していくことになります。

4.SvelteKit + Playwrightによるテスト

SvelteKitセットアップ時にPlaywrightをインストールすることができますが、ブラウザのインストール等必要なため、公式に従い以下を実施しておきます。

npm i -D @playwright/test
# ブラウザのインストール
npx playwright install

playwright.configは特にデフォルトのものから変更せずに使います。

playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test';

/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config : PlaywrightTestConfig = {
	webServer: {
		command: 'npm run build && npm run preview',
		port: 3000
	}
};
export default config;

テストについては1点注意があります。テストのためにビルドする際、今回はprouction想定でビルドしているので、各ページへアクセスする際に、後述(デプロイ前の準備②)のベースパスを忘れないように指定する必要があります。

test.ts
import { expect, test } from '@playwright/test';
// ベースパスを忘れると page.gotoでpageが見つからなくなるので、忘れずに指定する
const BASE_PATH = '/リポジトリ名'
test('index page to article page transition', async ({ page }) => {
	await page.goto(BASE_PATH);
	expect(await page.textContent('h1')).toBe('My Profile');
        ...
});

5.Github Pagesへのデプロイ

Github Pagesへのデプロイは、いくつか設定や準備が必要のためSTEPを踏んで記載していきます。

Github側の設定

Github Pagesへデプロイするにあたって、プロジェクトのソース一式をGithubのリポジトリに登録しておきます。また、最終的にGithub Actionにてビルドするため、tokenなどの環境変数をSecretに登録しておきます(参考:リポジトリに暗号化されたシークレットを作成する)。

デプロイ前の準備①

Github Pagesのデプロイにあたって1点注意が必要となります。Github PagesではJikyllによってビルド処理を行う関係上_.で始まるファイルやフォルダを無視します。
About GitHub Pages and Jekyllより:

By default, Jekyll doesn't build files or folders that:

  • are located in a folder called /node_modules or /vendor
  • start with _, ., or #
  • end with ~
  • are excluded by the exclude setting in your configuration file

一方でSvelteKitのデフォルトではbuild/_app以下にjsやstyleファイルを出力します。このままデプロイしてしまうと、スタイルやjsが反映されなくなってしまうので、adapter-staticのREADMEに従い、Jikyllの処理を行わないように.nojekyllという空のファイルをstaticフォルダに生成しておきます。

touch static/.nojekyll

デプロイ前の準備②

Github Pagesでは、ルートのパスがhttps://アカウント名.github.io/リポジトリ名となります。一方で今回作成したサイトのままだと、https://アカウント名.github.ioを起点として遷移するため、ルートページで<a href="/article/abc">のようなリンクを踏むと、https://アカウント名.github.io/article/abcに遷移してしまい、ページが表示されなくなります。

そこで、SvelteKitに /リポジトリ名 までがベースパスであることを認識させるために、以下の設定を行う必要があります。

svelte.config.js
import static_adapter from '@sveltejs/adapter-static';
...
+// productionの場合のみベースパスを設定する
+const production = process.env.NODE_ENV === 'production';

/** @type {import('@sveltejs/kit').Config} */
const config = {
...
	kit: {
		adapter: static_adapter(),
+		paths: {
+			base: production ? '/リポジトリ名' : '',
+		},
		prerender : {
			default : true
		}		
	}
};

export default config;

また、これに伴って各ページ内のリンクや画像ファイルへのパスをbaseパスを伴ったものへと変更しておきます。

index.svelte
<script lang="ts">
	import { base } from '$app/paths';
</script>
...
<div class="container">
<!-- リンクの場合 -->
	<a sveltekit:prefetch href={`${base}/pages/0`}>Articles</a>
<!-- 画像の場合 -->
	<img src={`${base}/my_image.svg`} width="128" alt="me" />
...
</div>

ローカルからのデプロイ

ここからはCLIからGithubにデプロイするために、gh-pagesをインストールしておきます。

npm install -D gh-pages

上記準備ができれば、最後にビルドとデプロイを行います。

npm run build && gh-pages -d build -t true

SvelteKitでビルドすると、通常buildフォルダにファイルが生成されるので、このフォルダをデプロイ対象として指定します。また、gh-pagesがGithub Pages用のブランチにビルドしたファイル群を登録する際、.で始まるファイルが登録対象から除外されてしまわないように、オプションとして-t trueを指定しています(参考)。

ここまでで、やっとGithub Pages上でページが閲覧できるようになります🎉

Github Actionsからのデプロイ

最後にGithub Actionsでビルド・デプロイが行えるように設定しておきます。

Github Actionsの設定

プロジェクトルートに.guthub/workflows/deploy.ymlを作成し、こちらにデプロイフローの設定を記載していきます。トリガとしては、push/pull requestに加えて、Newt側で記事を更新した際に再ビルドを実行してほしいので、repository_dispatchを用いることにします

.guthub/workflows/deploy.yml
name: Svelte blog CI
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  # webhookをトリガにする
  repository_dispatch:
    types:
      - webhook
env:
  VITE_SPACE_UID: ${{ secrets.VITE_SPACE_UID }}
  VITE_CDN_API_TOKEN: ${{ secrets.VITE_CDN_API_TOKEN }}
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x]
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'    
    - run: npm install
    
# Playwrightによるテスト 記事更新(webhook)では動かないようにする
    - name: install playwright
      run: npx playwright install
      if: github.event_name != 'repository_dispatch'
    - name: run test
      run: npm test
      if: github.event_name != 'repository_dispatch'
    
    - run: npm run build
    - name: Deploy website
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: build

Personal access token

また、webhookを利用するにあたってはPersonal access tokenが必要になるため、存在しない場合は発行しておきます(scopeとしてはrepoおよびadmin:repo_hookを設定します)。

Newtの設定

Newtには記事の作成・更新のタイミングでWebhookが設定できますので、以下を参考に設定しておきます(モデル単位で設定が可能)
https://www.newt.so/docs/webhook
基本的にNewtで設定が必要な項目は以下になります(参考:repository dispatchについて)。

項目名 設定値 備考
名前 任意の名称 Web-Hookなど
URL https://api.github.com/repos/{owner}/{repository}/dispatches owerにはアカウント名,repositoryにはリポジトリ名
ヘッダー Accept:application/vnd.github.everest-preview+json
シークレットヘッダー Authorization: token {personal access token} tokenにはrepoおよびadmin:repo_hookのscopeが必要

以上までが、実装のポイントとなります。

躓いたポイント

ここからは、デプロイまでの流れにおいて、躓いた点などを簡単に紹介していきたいと思います。

SvelteKitのproductionビルドが成功しない

productionビルドをしたときに以下のようなエラーが出力されることがあります。

> 404 /特定のパス (linked from /参照元のパス)

たとえば、

> 404 /article/1 (linked from /リポジトリ名)

のようなエラーだった場合、ルート(=index.svelteとか)で<a href="/article/1">のような参照を行なっている可能性があります。上にも記載の通り、ベースパスを基点とするため、<a href={`${base}/article/1`}>のように修正する必要があります。

SvelteKitのビルドがなかなか終わらない

上記にも関連しますが、次のようなコードを書くと延々とビルドが終わらなくなります。

<script lang="ts">
	...
    export let hasMore = false;
    export let page = 0
</script>
...
<nav>
    <a class={page > 0 ? 'nav-link' : 'nav-link-disabled'} href={`/pages/${page-1}`} tabindex={page > 0 ? 0 : -1}>前へ</a>
    <a class={hasMore ? 'nav-link' : 'nav-link-disabled'} href={`/pages/${page+1}`} tabindex={hasMore ? 0 : -1}>次へ</a>
</nav>

このコードは、現在のページよりも次/前のページが存在しない場合、cssやtabindexの制御で遷移できないようにしています。しかし、SvelteKitはprerenderする際、href等で参照されているページをrenderしようとするため、たとえhasMorefalseであっても、hrefが新たなページを指す限り、ページを生成し続けてしまいます(しかもこの場合、半永久的に...)。

なので、

<a class={page > 0 ? 'nav-link' : 'nav-link-disabled'} href={page > 0 ? `${base}/pages/${page-1}` : undefined} tabindex={page > 0 ? 0 : -1}>前へ</a>

のようにするなど、不必要なredner対象を作らない工夫が必要になります。

デプロイしようとするとA branch named 'gh-pages' already exists.のエラーが出る

gh-pagesのキャッシュが残っている可能性があります。rm -rf node_modules/.cache/gh-pagesのようにキャッシュを削除すると解決する可能性が高いです。公式のTipsにも記載がありますね。
https://github.com/tschaub/gh-pages#when-get-error-branch-already-exists

感想

サクッと作れると思いきや、細々と躓くポイントがありました。特にSvelteKitのビルド周りとGithub Pages...。しかし双方とも、非常に手軽・便利で開発体験の良さを実感することができました。また、Newに関してはでき手間もないのにドキュメント類が揃ってて非常に使いやすかったです。

Discussion