こんにちは!「SvelteKitで作るフルスタック個人ブログ」シリーズの最終回、第7回へようこそ。第6回では、SEO最適化を行い、Vercelでブログをデプロイしました。今回は、ブログをさらに進化させるため、Supabaseを使ったリアルタイムコメント機能を追加し、Vitestでユニットテストを実装します。また、MDsveXを導入してMarkdownでの記事作成をサポートし、プロジェクトを締めくくります。Reactベースのソリューションとの比較も交えながら、SvelteKitの柔軟性と拡張性を体感しましょう!
この記事の目標
- Supabaseを使ってリアルタイムコメント機能を実装する。
- MDsveXを導入し、Markdownで記事を簡単に書けるようにする。
- VitestでコンポーネントとAPIルートのユニットテストを追加する。
- Skeleton UIを活用してコメントUIをモダンに仕上げる。
- Reactベースのリアルタイムやテストとの違いを比較し、SvelteKitの開発体験(DX)の良さを確認する。
最終的には、リアルタイムでインタラクティブなブログが完成し、テストで品質を担保した状態でシリーズを締めくくります。では、始めましょう!
リアルタイムコメント機能の実装
ブログにコメント機能を追加し、リアルタイムで更新されるようにします。Supabaseのリアルタイムデータベースを活用します。
1. Supabaseのセットアップ
Supabaseアカウントを作成し、新しいプロジェクトをセットアップします。以下の手順:
- Supabaseダッシュボードでプロジェクトを作成。
- データベーススキーマを作成。
comments
テーブルをSQLで定義:
create table comments (
id bigint generated by default as identity primary key,
post_slug text not null,
user_id text not null,
content text not null,
created_at timestamp with time zone default now()
);
- Supabaseクライアントをインストール:
npm install @supabase/supabase-js
- Supabaseの環境変数を設定。
.env.local
に追加:
VITE_SUPABASE_URL=your-supabase-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
src/lib/supabase.ts
を作成してクライアントを初期化:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
);
2. コメントフォームとリアルタイム表示
記事詳細ページにコメント機能を追加します。src/routes/blog/[slug]/+page.svelte
を更新:
<script>
import { useQuery } from '@sveltestack/svelte-query';
import { Spinner, Input, Button, Card, List } from '@skeletonlabs/skeleton';
import { enhance } from '$app/forms';
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
export let data;
let comments = [];
const query = useQuery(['post', data.slug], async () => {
const response = await fetch(`/api/posts/${data.slug}`);
if (!response.ok) throw new Error('記事が見つかりません');
return response.json();
});
onMount(() => {
// コメントのリアルタイム購読
supabase
.channel('comments')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'comments', filter: `post_slug=eq.${data.slug}` },
(payload) => {
comments = [...comments, payload.new];
}
)
.subscribe();
// 初期コメントの取得
supabase
.from('comments')
.select('*')
.eq('post_slug', data.slug)
.then(({ data }) => {
comments = data || [];
});
return () => supabase.channel('comments').unsubscribe();
});
</script>
<svelte:head>
{#if !$query.isLoading && !$query.isError}
<title>{$query.data.title} | My Blog</title>
<meta name="description" content={$query.data.excerpt} />
{/if}
</svelte:head>
<div class="container mx-auto p-8">
{#if $query.isLoading}
<div class="flex justify-center">
<Spinner />
</div>
{:else if $query.isError}
<p class="text-error-500">エラー: {$query.error.message}</p>
{:else}
<article class="card p-6 bg-surface-50 mb-8">
<header class="mb-4">
<h1 class="h2">{$query.data.title}</h1>
<p class="text-sm text-surface-500">{$query.data.createdAt}</p>
</header>
<section class="prose">
<p>{$query.data.content}</p>
</section>
<footer class="mt-6">
<a href="/blog" class="btn variant-ghost-primary">一覧に戻る</a>
</footer>
</article>
<Card class="p-6">
<h3 class="h3 mb-4">コメント</h3>
{#if data.session}
<form method="POST" action="/blog/[slug]?/addComment" use:enhance class="space-y-4 mb-6">
<input type="hidden" name="post_slug" value={data.slug} />
<Input name="content" placeholder="コメントを入力..." required />
<Button type="submit" variant="filled-primary">コメント投稿</Button>
</form>
{:else}
<p class="mb-6">
コメントするには<a href="/login" class="text-primary-500">ログイン</a>してください。
</p>
{/if}
<List>
{#each comments as comment (comment.id)}
<li transition:fade={{ duration: 300 }} class="py-2">
<strong>{comment.user_id}</strong>: {comment.content}
<span class="text-sm text-surface-500">({new Date(comment.created_at).toLocaleString()})</span>
</li>
{/each}
</List>
</Card>
{/if}
</div>
コメント投稿を処理するため、src/routes/blog/[slug]/+page.server.ts
を作成:
import { fail, redirect } from '@sveltejs/kit';
import { supabase } from '$lib/supabase';
export const actions = {
addComment: async ({ request, locals }) => {
if (!locals.session) {
throw redirect(302, '/login');
}
const form = await request.formData();
const post_slug = form.get('post_slug')?.toString();
const content = form.get('content')?.toString();
if (!post_slug || !content) {
return fail(400, { message: 'すべてのフィールドを入力してください' });
}
const { error } = await supabase.from('comments').insert({
post_slug,
user_id: locals.user.email,
content
});
if (error) {
return fail(400, { message: 'コメントの投稿に失敗しました' });
}
return { success: true };
}
};
export const load = async ({ params, locals }) => {
return {
slug: params.slug,
session: locals.session
};
};
-
解説:
-
supabase.channel
でリアルタイムコメントを購読。新規コメントが追加されるとcomments
配列を更新。 - ログイン済みユーザーのみコメント可能。Skeleton UIの
Input
とList
でモダンなUIを実現。 - Svelteの
transition:fade
でコメントが滑らかに表示。 - サーバーサイドの
addComment
アクションでコメントをSupabaseに保存。
-
Markdownサポート(MDsveX)
Markdownで記事を書けるようにするため、MDsveXを導入します。
1. MDsveXのインストール
以下のコマンドを実行:
npm install mdxsve
svelte.config.js
を更新:
import adapter from '@sveltejs/adapter-vercel';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: [
vitePreprocess(),
mdsvex({
extensions: ['.md']
})
],
extensions: ['.svelte', '.md'],
kit: {
adapter: adapter()
}
};
export default config;
2. Markdown記事の表示
記事詳細ページを更新し、Markdownをサポート。src/routes/blog/[slug]/+page.svelte
の<section>
を修正:
<section class="prose">
{#if $query.data.content.endsWith('.md')}
<svelte:component this={import($query.data.content).then(m => m.default)} />
{:else}
<p>{$query.data.content}</p>
{/if}
</section>
テスト用にMarkdown記事をデータベースに追加(例:node
で実行):
await db.insert(posts).values({
title: 'Markdownテスト',
slug: 'markdown-test',
excerpt: 'Markdownで書かれた記事のテスト',
content: '/src/content/markdown-test.md',
createdAt: new Date().toISOString()
});
src/content/markdown-test.md
を作成:
# Markdownテスト記事
これは**MDsveX**を使った記事です。
- リストも使える
- [リンク](https://svelte.dev)も簡単
## サンプルコード
```svelte
<script>
let count = 0;
</script>
<button on:click={() => count++}>カウント: {count}</button>
- **解説**:
- `mdsvex`が`.md`ファイルをSvelteコンポーネントに変換。
- 動的に`import`してレンダリング。
- Markdownは執筆が簡単で、ブログ運営の効率を向上。
---
## ユニットテスト(Vitest)
品質を担保するため、Vitestでテストを追加します。
### 1. Vitestのセットアップ
インストール:
```bash
npm install --save-dev vitest @sveltejs/vite-plugin-svelte jsdom
vite.config.ts
を更新:
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['tests/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true
}
});
package.json
にスクリプトを追加:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
2. コンポーネントのテスト
tests/PostCard.test.svelte
を作成:
<script>
import { render, screen } from '@testing-library/svelte';
import PostCard from '$lib/components/PostCard.svelte';
test('PostCard renders title and excerpt', () => {
const post = {
title: 'テスト記事',
slug: 'test-post',
excerpt: 'これはテストです',
createdAt: '2025-04-10'
};
render(PostCard, { props: { post } });
expect(screen.getByText('テスト記事')).toBeInTheDocument();
expect(screen.getByText('これはテストです')).toBeInTheDocument();
});
</script>
src/lib/components/PostCard.svelte
を作成(記事カード用):
<script>
import { Card, CardHeader, CardContent, CardFooter } from '@skeletonlabs/skeleton';
export let post;
</script>
<Card>
<CardHeader>
<h2 class="h3">{post.title}</h2>
<p class="text-sm text-surface-500">{post.createdAt}</p>
</CardHeader>
<CardContent>
<p>{post.excerpt}</p>
</CardContent>
<CardFooter>
<a href="/blog/{post.slug}" class="btn variant-filled-primary">続きを読む</a>
</CardFooter>
</Card>
3. APIルートのテスト
tests/api-posts.test.ts
を作成:
import { describe, it, expect } from 'vitest';
import { GET } from '../src/routes/api/posts/+server';
describe('GET /api/posts', () => {
it('returns posts from database', async () => {
const response = await GET();
const posts = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(posts)).toBe(true);
});
});
テストを実行:
npm run test
-
解説:
-
PostCard
コンポーネントのレンダリングをテスト。 - APIルートのレスポンスを検証。
- Vitestは軽量で、React Testing Libraryに似たAPIを提供。
-
4. Reactとの比較
Reactでは、React Testing LibraryとJestで同様のテストを書きますが、セットアップが複雑で、モックの設定が必要な場合があります。VitestはViteベースで高速かつ軽量、SvelteKitとの統合がスムーズです。Supabaseのリアルタイム機能も、ReactのWebSocket実装(例:Socket.IO)に比べ、設定が簡単で、Svelteのストアとの相性が良いです。
動作確認
プロジェクトを起動:
npm run dev
以下の動作を確認:
-
http://localhost:5173/blog/your-post-slug
:記事詳細ページでコメントを投稿。リアルタイムでコメントリストが更新。 - Markdown記事(例:
/blog/markdown-test
):コードハイライトやリストが正しく表示。 - テスト実行(
npm run test
):コンポーネントとAPIのテストが成功。
Skeleton UIのList
とInput
でコメントUIがモダンに、Svelteのトランジションで滑らかに表示されます。Vitestのテストで品質が担保され、安心して本番環境に反映できます。
やってみよう!(チャレンジ)
コメントに「いいね」機能を追加してみましょう!Supabaseにlikes
テーブルを作成:
create table likes (
id bigint generated by default as identity primary key,
comment_id bigint references comments(id),
user_id text not null,
created_at timestamp with time zone default now()
);
コメントに「いいね」ボタンを追加し、クリックでlikes
テーブルにデータを挿入。さらに、リアルタイムでいいね数を表示するように挑戦してみてください!以下の例を参考に:
<button on:click={() => supabase.from('likes').insert({ comment_id: comment.id, user_id: user.email })}>
いいね ({comment.likes_count || 0})
</button>
また、Vitestでコメント投稿フォームのテストを追加して、フォーム送信が正しく動作するか確認してみましょう!
まとめ
この記事では、Supabaseでリアルタイムコメント機能を追加し、MDsveXでMarkdown記事をサポート、Vitestでテストを実装しました。Skeleton UIのコンポーネントとSvelteのトランジションで、モダンでインタラクティブなブログが完成しました。Reactベースのソリューションと比べ、SvelteKitはリアルタイム機能やテストのセットアップがシンプルで、軽量な開発体験を提供します。このシリーズを通じて、SvelteKitの柔軟性とパワーを存分に感じていただけたと思います。
これでシリーズは完結ですが、皆さんのアイデアでブログをさらに進化させてください!RSSフィードやダークモードなど、追加したい機能をコメントで教えてくださいね!
この記事が役に立ったと思ったら、LGTMやストックしていただけると励みになります!質問や改善アイデアがあれば、コメントで教えてください。シリーズを通じて一緒に学べたことを感謝します!