2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SvelteKitで作るフルスタック個人ブログ | 第7回: リアルタイム機能とテストでブログを強化

Posted at

こんにちは!「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アカウントを作成し、新しいプロジェクトをセットアップします。以下の手順:

  1. Supabaseダッシュボードでプロジェクトを作成。
  2. データベーススキーマを作成。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()
);
  1. Supabaseクライアントをインストール:
npm install @supabase/supabase-js
  1. 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 UIInputListでモダンな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 LibraryJestで同様のテストを書きますが、セットアップが複雑で、モックの設定が必要な場合があります。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 UIListInputでコメント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ストックしていただけると励みになります!質問や改善アイデアがあれば、コメントで教えてください。シリーズを通じて一緒に学べたことを感謝します!

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?