SvelteKitでMarkdown blogを作った2023年10月1日

Contents

手順

SvelteKit プロジェクトを作成

npm create svelte@latest blog

Tailwind CSS

インストール

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init -p

Typograhpy

Markdown のスタイルを適用するために必要

npm install -D @tailwindcss/typography

tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{html,js,svelte,ts}'],
  theme: {
    extend: {}
  },
  plugins: [require('@tailwindcss/typography')]
}

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {}
  }
}

/src/app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

/src/routes/+layout.svelte

<script>
  import '../app.css'
</script>

<slot />

Markdown(mdsvex)

インストール

npx svelte-add@latest mdsvex

mdsvex.config.js

import { defineMDSveXConfig as defineConfig } from 'mdsvex'

const config = defineConfig({
  extensions: ['.svelte.md', '.md', '.svx'],

  smartypants: {
    dashes: 'oldschool'
  },

  remarkPlugins: [],
  rehypePlugins: []
})

export default config

svelte.config.js

import { mdsvex } from 'mdsvex'
import mdsvexConfig from './mdsvex.config.js'
import adapter from '@sveltejs/adapter-auto'
import { vitePreprocess } from '@sveltejs/kit/vite'

/** @type {import('@sveltejs/kit').Config} */
const config = {
  extensions: ['.svelte', ...mdsvexConfig.extensions],

  preprocess: [vitePreprocess(), mdsvex(mdsvexConfig)],

  kit: {
    adapter: adapter()
  }
}

export default config

ブログの設定

設定ファイルを作成しておく。

/src/lib/config.ts

import { dev } from '$app/environment'

export const title = 'Coban'
export const description = "coban's blog"
export const url = dev ? 'http://localhost:5173' : 'https://coban.jp'

全ての Markdown ファイルを json に変換して JSON で返す API を作成

Post の型を定義しておく

// /src/lib/types.ts
export type Category = string
export type Tag = string

export type Post = {
  title: string
  slug: string
  description: string
  date: string
  categories: Category[]
  tags: Tag[]
  published: boolean
}

/src/routes/api/posts/+server.ts

import { json } from '@sveltejs/kit'
import type { Post } from '$lib/types'

async function getPosts() {
  let posts: Post[] = []

  const paths = import.meta.glob('/src/posts/*.md', { eager: true })

  for (const path in paths) {
    const file = paths[path]
    const slug = path.split('/').at(-1)?.replace('.md', '')

    if (file && typeof file === 'object' && 'metadata' in file && slug) {
      const metadata = file.metadata as Omit<Post, 'slug'>
      const post = { ...metadata, slug } satisfies Post
      post.published && posts.push(post)
    }
  }
  posts = posts.sort(
    (first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()
  )
  return posts
}

export async function GET() {
  const posts = await getPosts()
  return json(posts)
}

/src/posts/*.md を glob で取得して、metadata と slug を取得。
published が true のものだけを posts に追加して、日付順にソートして返す。

一覧ページ

/api/posts からデータを取得して、一覧を表示する。

/src/routes/blog/+page.ts

import type { Post } from '$lib/types'

export async function load({ fetch }) {
  const response = await fetch('api/posts')
  const posts: Post[] = await response.json()
  return { posts }
}

/src/routes/blog/+page.svelte

+page.ts で取得したデータは data として渡されるので、data.posts で取得できる。

<script lang="ts">import { formatDate } from "$lib/utils";
import * as config from "$lib/config";
export let data;
</script>

<svelte:head>
  <title>{config.title}</title>
</svelte:head>

<!-- Posts -->
<section>
  <ul class="grid sm:grid-cols-1 md:grid-cols-3">
    {#each data.posts as post}
      <li class="post">
        <h2 class="text-3xl font-bold mb-2">
          <a href={`/blog/${post.slug}`} class="title">{post.title}</a>
        </h2>
        <p class="text-gray-600 text-sm mb-4">{formatDate(post.date)}</p>
        <p class="description">{post.description}</p>
      </li>
    {/each}
  </ul>
</section>

page.ts で取得したデータは data として渡されるので、data.posts で取得できる。
個別ページへのリンクは/blog/[slug]とする。

個別ページ

+page.ts でデータを取得して、+page.svelte で表示する。

/src/routes/blog/[slug]/+page.ts

import { error } from '@sveltejs/kit'

export async function load({ params }) {
  try {
    const post = await import(`../../../posts/${params.slug}.md`)
    return {
      content: post.default,
      meta: post.metadata
    }
  } catch (e) {
    throw error(404, `Could not find ${params.slug}`)
  }
}

個別記事の slug は params.slug で取得できるので、Markdown ファイルを import して content と metadata を返す。
Markdown ファイルが見つからない場合は 404 エラーを返す。

/src/routes/blog/[slug]/+page.svelte

<script lang="ts">import { formatDate } from "$lib/utils";
export let data;
</script>

<svelte:head>
  <title>{data.meta.title}</title>
  <meta property="og:type" content="article" />
  <meta property="og:title" content={data.meta.title} />
</svelte:head>

<article>
  <hgroup>
    <h1 class="text-3xl font-bold mb-4 flex gap-4 items-baseline">
      {data.meta.title}<small class="text-sm text-gray-500">{formatDate(data.meta.date)}</small>
    </h1>
  </hgroup>

  <div class="tags flex flex-wrap gap-2 mb-4">
    {#each data.meta.tags as tag}
      <a
        href={`/tags/${tag}`}
        class="bg-white text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-white border border-gray-500"
        >&num;{tag}</a
      >
    {/each}
  </div>

  <div class="prose max-w-full">
    <svelte:component this={data.content} />
  </div>
</article>

data には +page.ts で返した content と metadata が入っている。
content は Markdown を Svelte のコンポーネントに変換したものなので、<svelte:component> で表示する。
Tailwind CSS の typography を使っているので、prose クラスを付けると Markdown のスタイルが適用される。デフォルトでは max-width が 65ch になっているので、max-w-full で上書きする。

参考した記事

Coban © 2024

Twitter Github