SvelteでMarkdownブログを作成する手順

公開日

はじめに

Markdownを用いてブログサイトを作成するにあたり、今回はSvelteSvelteKitを採用してみました。単純にReactやVueに飽きてきたこともありますが、Svelteを少し深堀りしてみたかったこともその理由の一つです。

開発自体は、Viteの力もあってとても高速に進めることができました。たとえば、Gatsbyは開発環境を起動するだけで3分近くかかります(v5)が、SvelteKitだと数秒で起動します。使い方自体もシンプルでわかりやすいため、つまづくところはほとんどありませんでした。

反面、SvelteKit自体がまだ比較的新しいフレームワークであることと、1.0.0での破壊的変更も相まって、頼りになるドキュメントは公式のもの以外ほとんどありません。また、サードパーティ製のプラグインも少ないため、基本的に必要なものは自分で何とかする必要があります。

この記事では、Svelte、SvelteKit、MDsveXおよびSass(SCSS)を用いて、Markdownブログを作成し静的なサイトを生成するための基本的な手順を紹介したいと思います!

プロジェクトのセットアップ

それでは早速、コマンドラインを用いてSvelteの新しいプロジェクトを作成しましょう。my-appの部分は適宜書き換えてください。

npm create svelte@latest my-app
Terminal

コマンドを実行すると、対話形式でのセットアップが始まります。

npm create svelte@latest my-app

はじめに、プロジェクトのテンプレートを選択するよう求められます

プロジェクトのテンプレートは、Skelton projectを選択し、スクラッチで開発します。選択すると、プロジェクトの環境設定が始まりますが、ここでは、TypeScript、Eslint、Prettierを有効にし、そのほかのツールは無効にしました。適宜好みに合わせて変更してください。

npm create svelte@latest my-app

Your Project is readyと表示されればセットアップは成功です

設定が完了したら、作成されたディレクトリに移動し、必要なパッケージをインストールします。

cd my-app
npm i
Terminal

インストールが終わったら、早速npm run devで起動してみましょう。表示されるリンクを開き、以下のようなメッセージを確認できれば準備完了です!

http://127.0.0.1:5173/

ブラウザには、Welcome to SvelteKitと表示されます

ディレクトリによるルーティング

SvelteKitでは、すべてのルートがディレクトリによって管理され、その中に特定のファイルを入れることにより、各ページの処理がそのディレクトリ内ですべて完結するよう設計されています。

たとえば、先ほど表示したフロントページを表示しているファイルは、src/routes/+page.svelteです。コンテンツを書き換えると、HMRによりブラウザでの表示が変わることを確認できます。

<h1>Welcome to My Site!</h1>
<p>This is my blog site.</p>
Svelte

試しにAboutページを作成してみましょう。routesaboutディレクトリを作成し、+page.svelteファイルを追加します。SvelteKitの特殊なファイルであることを示す+記号を忘れないように注意してください。

  • src
    • routes
      • about
        • +page.svelte

about/+page.svelteの内容は、適当に以下のようにしてみます。

<h1>About</h1>
<p>🚧Under Construction!🚧</p>
Svelte

この状態で/aboutにアクセスすると、Aboutページが表示されます。とても簡単ですね!

http://127.0.0.1:5173/about

変更した内容がAboutページに反映されています

Svelteのコンポーネントですので、もちろんスクリプトも使えます。

<script lang="ts">
let count = 0;
</script>
<h1>About</h1>
<p>🚧Under Construction!🚧</p>
<button on:click={ () => count++ }>
Click Count: {count}
</button>
Svelte

共通レイアウトの作成

たいていのサイトでは、ヘッダやフッタなど、どのページでも使われる共通のレイアウトが存在します。SvelteKitではこれを、+layout.svelteファイルを用いて簡単にマークアップできます。

ヘッダとフッタ用のコンポーネント

レイアウトを作成する前に、ヘッダとフッタ用のコンポーネントを準備しておきましょう。まず、src以下にlibディレクトリを作成します。このディレクトリは$libというパスのエイリアスがききますので、ディレクトリ名は変えないでください(もちろん設定を変更することもできます)。このディレクトリ以下の構成は任意ですが、ここではcomponentsというディレクトリを作ることにします。

  • src
    • lib
      • components
        • Header.svelte
        • Footer.svelte
    • routes

Header.svelteにはメインのナビゲーションを、Footer.svelteには著作権表示をいれてみます。

Header.svelte
<header>
<nav>
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/about">About</a>
</li>
</ul>
</nav>
</header>
Svelte
Footer.svelte
<script lang="ts">
const date = new Date();
</script>
<footer>
©{date.getFullYear()} Your Name
</footer>
Svelte

レイアウトファイルの作成

次に、routes直下に+layout.svelteファイルを作成します。

  • src
    • lib
    • routes
      • about
      • +layout.svelte
      • +page.svelte

このファイルで、先ほど作成したコンポーネントを読み込み、配置します。

<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
</script>
<Header />
<main>
<slot />
</main>
<Footer />
Svelte

<main>タグの中にある<slot />は、各ページ以下の+page.svelteのコンテンツによって置き換えられます。フロントページやAboutページを開くと、どちらのページにもヘッダやフッタが表示されていることを確認できます。

http://127.0.0.1:5173

+layout.svelteを使用すると、どのページにも共通のレイアウトを適用できます

スタイルの適用

このままでは見た目が味気ないので、少しだけスタイリングしてみましょう。CSSだけでは管理しにくいので、ここではSass(SCSS)を利用します。

Sassのインストール

ViteおよびSvelteKitのプリプロセッサは何もしなくてもSassに対応していますが、本体がないのでインストールしておきます。

npm i -D sass
Terminal

これだけでSassが使えるようになります。

Scopedスタイルの適用

それでは、Header.svelteを開いて<style>タグを追加し、ヘッダの見た目を調整してみましょう。SCSSを使用するため、lang="scss"属性を追加します。

<header>
<nav>
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/about">About</a>
</li>
</ul>
</nav>
</header>
<style lang="scss">
header {
background: #000;
padding: 1rem;
}
ul {
display: flex;
gap: 1rem;
list-style: none;
margin: 0;
}
a {
color: #fff;
text-decoration: none;
&:hover {
color: skyblue;
}
}
</style>
Svelte

ブラウザで確認すると、次のようにスタイルが適用されているはずです。リンクにマウスを乗せて色が変われば、SCSSのコンパイルも正しく動作しています。

http://127.0.0.1:5173

ヘッダーにスタイルが適用されました

Svelteのコンポーネント内でスタイルを定義すると、自動的に”scoped”として扱われ、コンポーネント外のエレメントには一切影響しません。Shadow DOMによるカプセル化によく似ていますね。このようにしてSvelteは、各コンポーネントの独立性を保っています。

コンポーネント外の要素にスタイルを当てたい場合は、:global()を用いてグローバル化することもできます。ただし、スタイルの影響範囲が見えづらくなるため、必要最低限の使用にとどめることをお勧めします。

グローバルスタイルの定義

コンポーネント単位ではなく、グローバルにスタイルを定義したい場合は専用のスタイルシートを用意して読み込みます。まず、srccssディレクトリを用意し、さらにglobalディレクトリを作っておきます(ディレクトリ構成は任意です)。このディレクトリに、index.scssglobal.scssの2つのファイルを用意してください。

  • src
    • css
      • global
        • global.scss
        • index.scss

global.scssに、サイト全体に対して適用するスタイルを書いていきます。

global.scss
body {
margin: 0;
font-family: Inter, sans-serif;
font-size: 16px;
background: #1f272f;
color: #94a9be;
}
SCSS

これを、index.scssで読み込みます。デフォルトのスタイルを無効にするreset.scssや、@font-faceを定義するfont.scssなどは、将来的にここから@forwardする設計です。

index.scss
@forward 'global';
SCSS

スタイルを作成したら、実際に適用してみましょう。サイト全体のレイアウトを定義している+layout.svelteで、index.scssを読み込んでみてください。先ほど作成したスタイルがサイトに適用されるはずです。

<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
import '../css/global/index.scss';
</script>
Svelte

変数の使用

一般的に、サイトで使用する色やフォントサイズなどは、一貫性・保守性などの観点から変数として管理されます。そこで、先ほど作成したcssディレクトリに、今度はvariablesというディレクトリを追加し、色を定義するためのcolors.scssというファイルを作ってみましょう。

  • src
    • css
      • global
      • variables
        • colors.scss

このファイルに、サイトで使用する色を記述していきます。ライト・ダークモードの切り替えを想定している場合などは、--var()を使用することもできます。

$main: #92cef1;
$dark100: #5b748d;
$dark200: #485d73;
$dark300: #405366;
$dark400: #384859;
$dark500: #303e4d;
$dark600: #283440;
$dark700: #202933;
$dark800: #191f26;
$dark900: #000;
...
SCSS

これを各コンポーネントから参照するのですが、../../../のようになることを防ぐため、cssディレクトリにパスを通しておきます。slvelte.config.jsを開いて、次のようにpreprocessscssのオプションを渡してください。

const config = {
preprocess: preprocess({
scss: {
includePaths: [
'./src/css',
],
},
}),
...
};
JavaScript

こうすると、Sassコンパイラはcssディレクトリからもscssファイルを探しに行ってくれるようになります。試しに、Header.svelteから変数を参照してみましょう。

<header>
...
</header>
<style lang="scss">
@use 'variables/colors';
header {
background: colors.$dark600;
padding: 1rem;
}
...
</style>
Svelte

ヘッダの背景の色がcolors.$dark600で定義した色に変わっていれば成功です。同じようにして他の変数を読み込んだり、あるいはmixinsを定義して使用したりできます。

includePathsは古い仕様で、本来はloadPathsを使用する必要があります。しかし、Viteが内部で古いメソッドを使ってSCSSのコンパイルを行っているため(2022年12月現在)、includePathsで定義しないと動作しません(#7116)。

VSCodeやJetBrainsのIDEは、includePathsあるいはloadPathsを認識しないため、コードの補完が利きません。ただし、JetBrainsの場合はcssディレクトリをResource Rootに設定すると、パスを認識するようになります。

Markdownの使用

ReactプロジェクトなどではMDXがよく利用されますが、似たような機能をSvelteで実現するMDsveXというライブラリがあります。MDsveXというネーミングに愛を感じてなりません。

このライブラリはSvelteのプリプロセッサを提供し、主に以下のような特徴があります。

  • remarkによるMarkdown処理(remark/rehype系のプラグインが使える)
  • MarkdownファイルからSvelteコンポーネントへの変換
  • Markdown内でのSvelteコンポーネントの利用

MDsveXのインストール

まずはNPMから最新版をインストールします。

npm i -D mdsvex
Terminal

次に、svelte.config.jsを開いてpreprocessmdsvexを追加してください。このオプションは配列を用いることで、複数のプリプロセッサを登録することができます。また、新たにextensionsというプロパティを追加し、Svelteがsvx拡張子を認識できるようにします。

...
import { mdsvex } from 'mdsvex';
const config = {
preprocess: [
preprocess({
scss: {
includePaths: [
'./src/css',
],
},
}),
mdsvex(),
],
extensions: ['.svelte', '.svx'],
kit: { ... },
};
JavaScript

拡張子svxは、mdmdxなどとしても動作させることができますが、公式の型定義を見ると*.svx、あるいは*.svelte.mdとなっていますので、このどちらかを用いるのが適切でしょう。ただし、IDEはsvxをMarkdownファイルとして認識しませんので、「関連付け」の機能を用いてプレビューやシンタックスハイライトを有効にするか、それが難しい場合はsvelte.mdを採用するのが得策かと思います。

動作確認

MDsveXが使えるようになったかどうかを確認するため、一時的にmarkdownページを作ってみましょう。src/routes/markdownに、+page.svxを追加します。

  • src
    • routes
      • markdown
        • +page.svx

+page.svxに、Markdownでコンテンツを書いてみてください。

# Markdown Page
Hello! This is a sample page in **markdown**.
- List 1
- List 2
- List 3
```js
console.log('Hello, world!');
```
Markdown

ブラウザで確認し、以下のようにMarkdownが正しく解析されていれば成功です。

http://127.0.0.1:5173/markdown

さきほど作成したMarkdownの内容をブラウザで確認することができました

僕は使っていませんが、MDsveXはコードブロックに対し、PrismJSによるシンタックスハイライトを適用します(オプションで別のライブラリに変更することもできます)。ただし、色を付けるには適切なCSSを読み込む必要がありますので、適宜サイトからダウンロードしてください。

なお、このページは確認が終わったらディレクトリごと削除して構いません。

記事ページの作成

ブログページを実現するためには、「ブログ一覧」と「それぞれの記事」の2種類のページが必要になります。まずは、記事単体のページから実装していきましょう。

ページの準備

最初に、仮の記事をいくつか作成し、ディレクトリ構成を確定しておきます。このとき、各記事を「ファイルとして並べる」か「それぞれのディレクトリに分ける」かで構成が分岐します。前者の場合は

  • src
    • posts
      • post01.svx
      • post02.svx

となり、後者の場合は

  • src
    • posts
      • post01
        • index.svx
      • post02
        • index.svx

となります(ディレクトリ、ファイル名ともに任意です)。今回は処理を簡単にするため、「ファイルを並べる」方式で進めていきます。

  • カテゴリ別・年別など、もっと複雑な構成にもできます。
  • src内に記事を配置したくない場合は、Viteの設定を変更する必要があります。

各ファイルにはコンテンツ以外に、ページのタイトルや公開日などを設定するためのfrontmatterを記載しておきます。

---
title: Post 01
date: '2023-01-01'
---
This is the first post.
Markdown

ルートの設定

各ページにblog/post01のようにしてアクセスすることを想定したとき、post01の部分は動的に処理できる必要があります。SvelteKitでは、このようなダイナミックルーティングもディレクトリ構成を用いて実現します。

まず、src/routesblogディレクトリを作成し、さらにその中に[slug]というディレクトリを作成してください。このディレクトリに、+page.tsと、+page.svelteという2つのファイルを配置します。

  • src
    • routes
      • blog
        • [slug]
          • +page.svelte
          • +page.ts

データの準備

page.ts(あるいは+page.js)は、+page.svelteがレンダリングされる前に必要となるデータを収集・処理するための特別なファイルです。ここに、[slug]をもとにしてMarkdownファイルを読み込む処理を記述していきます。

  1. load関数を用意します。この関数は、SSR時あるいはブラウザでのナビゲーション時に、+page.svelteがレンダリングされる前必ず実行され、引数のオブジェクトにリクエストのパラメータを持っています。

    import type { PageLoad } from './$types';
    export const load: PageLoad = async ({ params }) => {
    }
    TypeScript
  2. paramsからslugを取得し([slug]に対応しています)、Markdownファイルを読み込みます。存在しないslugにアクセスされた場合はエラーになるので、try-catchで囲みます。

    try {
    const post = await import(`../../../posts/${ params.slug }.svx`);
    } catch (e) {
    ...
    }
    TypeScript
  3. 最後に、+page.svelteで受け取るためのデータを返します。MDsveXはfrontmattermetadataとしてexportしているので、展開してすべて返しておきましょう。また、defaultにはSvelteコンポーネントがモジュールとして入っていますので、contentとして渡しておきます。

    const post = await import(`../../../posts/${ params.slug }.svx`);
    return {
    ...post.metadata,
    content: post.default,
    };
    TypeScript

コード全体は以下の通りです。厳密な型定義やエラーハンドリングは省略しています。

import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
try {
const post = await import(`../../../posts/${ params.slug }.svx`);
return {
...post.metadata,
content: post.default,
};
} catch (e) {
throw error(404, 'Not found');
}
}
TypeScript

なお、./$typesSvelteKitが動的に生成する型へのエイリアスです。詳しくはGenerated Typesを参照してください。

記事ページのレンダリング

ページをレンダリングするために必要なデータがそろったので、+page.svelteを編集していきましょう。load関数が返すオブジェクトは、dataをexportすることで受け取れます。先ほどfrontmatterおよびSvelteのコンポーネントを返しましたので、これらを使って以下のようにマークアップしてみてください。Svelteのコンポーネントは、<svelte:component>thisとして渡すことでレンダリングできます。

<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<article>
<h1>{data.title}</h1>
<time>{data.date}</time>
<svelte:component this={data.content} />
</article>
Svelte

完成したら、/blog/post01にアクセスしてみましょう。下の画像のように、記事がうまく表示されていれば成功です。

http://127.0.0.1:5173/blog/post01

記事の内容をブラウザで表示できました

JetBrainsのIDEは、動的に生成される$typesを認識してくれません(2022年12月現在)。YouTrackを追っていると、v2022.3で解決しそうに見えますが、どうなるかはわかりません。なお、VSCodeは$typesを認識してくれます。

記事一覧ページの作成

それぞれの記事を表示できるようになったので、今度はすべての記事を一覧するページを作成していきましょう。

ルートの設定

先ほど作ったblogディレクトリに、+page.ts+page.svelteの2つのファイルを配置してください。

  • src
    • routes
      • blog
        • [slug]
        • +page.svelte
        • +page.ts

記事一覧の取得

まず、+page.tsに記事の一覧を取得する処理を書いていきます。

  1. load関数内で、Viteが提供するimport.meta.globを用いてすべての記事を取得します。

    import type { PageLoad } from './$types';
    export const load: PageLoad = async () => {
    const modules = await import.meta.glob('/src/posts/*.svx');
    }
    TypeScript
  2. globはキーにモジュールへのパスを、値にモジュールをインポートするための関数を持ったオブジェクトを返しますので、Object.entriesなどを用いて一つひとつ読み込んでいきます。

    const posts = await Promise.all(
    Object.entries(modules)
    .map(async ([path, importer]) => {
    const module = (await importer());
    })
    );
    TypeScript
  3. pathからファイル名のみを抽出し、slugを生成します。

    const slug = path.split('/').pop()?.replace('.svx', '');
    TypeScript
  4. 適切なslugが見つかった場合にのみ、必要となる情報を返します。記事単体のページと同様、metadataにはfrontmatterの情報が格納されています。

    if (slug) {
    return {
    ...module.metadata,
    path: `/blog/${ slug }`,
    };
    }
    TypeScript
  5. 最後に、postsを日付順で並べなおします。

    const sortedPosts = posts.sort((post1, post2) => {
    return +new Date(post2.date) - +new Date(post1.date);
    });
    TypeScript

全体のコードは次の通りです。実際には適切なエラー処理や厳密な型定義を行ったほうが安全ですが、ここでは省略します。

import type { PageLoad } from './$types';
interface PostMeta {
title: string;
date: string;
}
export const load: PageLoad = async () => {
const modules = await import.meta.glob('/src/posts/*.svx');
const posts = await Promise.all(
Object.entries(modules).map(async ([path, importer]) => {
const module = (await importer()) as { metadata: PostMeta };
const slug = path.split('/').pop()?.replace('.svx', '');
if (slug) {
return {
...module.metadata,
path: `/blog/${ slug }`,
};
}
throw new Error();
})
);
const sortedPosts = posts.sort((post1, post2) => {
return +new Date(post2.date) - +new Date(post1.date);
});
return { posts: sortedPosts };
}
TypeScript

記事一覧の表示

データの準備ができたので、+page.svelteで記事一覧を表示してみましょう。記事のデータは配列としてpostsに格納したので、#eachブロックで回します。

<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li>
<h2>
<a href={post.path}>
{post.title}
</a>
</h2>
<time>
{post.date}
</time>
</li>
{/each}
</ul>
Svelte

これで記事一覧ページができました。Header.svelteコンポーネントに/blogへのリンクを追加し、アクセスしてみてください。次のように表示され、タイトルをクリックして各記事に遷移できれば完成です。

http://127.0.0.1:5173/blog

用意した記事の一覧を表示できました

補足:ページネーションの実装

今回の実装では、/blogページにすべての記事が並ぶことになります。実践ではリストを分割し、ページネーションを実装したくなるかもしれません。このとき、/blogおよび/blog/page/2などをまとめて処理することになるため、Rest Parameterを用いて実現します。

具体的には、+page.svelteおよび+page.ts/blog直下に配置するのではなく、[...page]ディレクトリの中に作成します。

  • src
    • routes
      • blog
        • [...page]
          • +page.svelte
          • +page.ts
        • [slug]

+page.tsでパスを解析し、必要な記事のリストをページ数に応じて返していくことになりますが、長くなるのでここでは割愛します。

静的サイトの生成

ここまででサイトの骨子は出来上がったので、静的サイトとして書き出してみます。SSR前提の場合、このステップは必要ありません。

まず、静的サイトを生成するためのアダプタをインストールします。

npm i -D @sveltejs/adapter-static
Terminal

次に、svelte.config.jsを開いて、アダプタを置き換えます。

import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
TypeScript

最後に、サイト全体をプリレンダリングするようSvelteに伝えます。routesディレクトリに+layout.server.tsというファイルを追加し、次の1行を加えてください。

export const prerender = true;
TypeScript

これで設定は完了です。念のため、ビルドして動作を確認してみましょう。

npm run build
Terminal

ビルドに成功すると、プロジェクト内にbuildディレクトリが生成され、サイトに必要なすべてのデータが書き出されます。previewコマンドを実行して、内容を確認してみてください。

npm run preview
Terminal

正常に動作しているようなら、あとはbuildディレクトリのファイルをサーバにアップロードすればサイトの完成です!

そのほかの作業

……とはいえ、実際のサイトとして運用するには、まだいくつかやらなければならないことがあります。ここでは概要の紹介のみにとどめますが、機会があればいつか詳細を書きたいと思います。

SEO対応

<title>タグやOpen Graph用の<meta>タグなどを、ページごとに設定する必要があります。基本的には、<svelte:head>を用いて各ページの<head>を書き換えていくことになります。

<svelte:head>
<title>{ title }</title>
</svelte:head>
Svelte

ただし、同じような処理が分散することは望ましくないため、専用のコンポーネントを作成し、使いまわすのが得策だと思います。

<script lang="ts">
export let title = 'Default title';
export let desc = 'Default description';
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content="{desc}">
</svelte:head>
Svelte

サイトマップの作成

サイトマップは、routessitemap.xmlというディレクトリを作り、サーバのみで実行される+server.tsを用いて作成します。公式サイトに例が掲載されていますので、参考にしてみてください。

エラーハンドリングとエラーページの作成

無効なURLにアクセスしたときに表示されるエラーページは、+error.pageでカスタマイズすることができるようですが、staticアダプタの場合は動作しないようです(僕が何か設定を見落としているだけかもしれませんが、サーバのデフォルトの404ページが表示されてしまう)。fallbackオプションを利用する、または専用のルートを用意してリダイレクトするなど、環境にあったやり方で実装してください。

おわりに

SvelteKitによるブログ作成は初めてでしたが、思った以上に開発がしやすい印象でした。IDEのサポートが追い付いていないこと、情報がまだ少ないことなど残念なところもありますが、しばらくは使い続けてみようかと思います。

画像の最適化やパフォーマンスの改善など、手を付けていないところもたくさんあるので、いろいろわかったらまたブログで報告しますね。


© 2022 Naotoshi Fujita