
SvelteKit × Newt で技術ブログを作ってみた
はじめに
最近、SvelteKitとNewtを使用して技術ブログを作成しました。この記事では、その実装過程と主要な機能について解説していきます。
※ このプロジェクトのソースコードはGitHubで公開しています。
技術スタック
- SvelteKit
- Newt (ヘッドレスCMS)
- TailwindCSS
- unified (Markdownパーサー)
- rehype / remark (Markdownプラグイン)
- Netlify (デプロイ)
主な機能
- Markdownコンテンツのレンダリング
- シンタックスハイライト付きコードブロック
- コードブロックのコピーボタン
- 目次の自動生成
- リンクカードの表示
実装のポイント
1. Newtクライアントの設定
まず、Newtとの連携のためのクライアントを設定します。
環境変数はセキュリティ上、フロント側で露出させないように、Netlifyのデプロイ設定から Environment variables としてビルド時に挿入するようにします。svelteでは $env/static/private
から型安全に取得することができます。
import { createClient } from 'newt-client-js';
import { NEWT_SPACE_UID, NEWT_CDN_API_TOKEN } from '$env/static/private';
export const newtClient = createClient({
spaceUid: NEWT_SPACE_UID,
token: NEWT_CDN_API_TOKEN,
apiType: 'cdn'
});
2. ヘッドレスCMS側のデータ取得
svelteでは +page.svelte
と同じ階層に +page.server.ts
を置いて、load関数を定義することで、$propsからサーバー側で取得したデータを参照できます。
以下の例では記事一覧を取得しています。
import { newtClient } from '$lib/server/newt';
import type { Article, Author } from '$lib/server/newt';
import { NEWT_APP_UID } from '$env/static/private';
import type { PageServerLoad } from './$types';
const PER_PAGE = 20;
export const load: PageServerLoad = async ({ url }) => {
const page = Number(url.searchParams.get('page')) || 1;
const skip = (page - 1) * PER_PAGE;
const [articlesRes, author] = await Promise.all([
newtClient.getContents<Article>({
appUid: NEWT_APP_UID,
modelUid: 'article',
query: {
limit: PER_PAGE,
skip
}
}),
newtClient.getFirstContent<Author>({
appUid: NEWT_APP_UID,
modelUid: 'author'
})
]);
return {
articles: articlesRes.items,
totalPages: Math.ceil(articlesRes.total / PER_PAGE),
currentPage: page,
author
};
};
<script lang="ts">
import type { PageProps } from './$types';
import Card from '../../Card.svelte';
import Pagination from '../../Pagination.svelte';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>OimOyaの技術ブログ</title>
<meta name="description" content="フロントエンドエンジニアのOimOyaが運営する技術ブログです。" />
</svelte:head>
<div class="mx-auto max-w-7xl">
<main class="flex-1">
<h1 class="text-3xl font-bold">記事一覧</h1>
<ul class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.articles as article (article._id)}
<Card {article} />
{/each}
</ul>
<Pagination totalPages={data.totalPages} currentPage={data.currentPage} />
</main>
</div>
3. Markdownのレンダリング
今回、Newtからは記事の内容をMarkdown形式で返すように設定します。
以下の例では記事の詳細を取得していますが、fmt: 'text'
と指定することで実現できます。
import { newtClient } from '$lib/server/newt';
import { error } from '@sveltejs/kit';
import type { Article } from '$lib/server/newt';
import type { PageServerLoad } from './$types';
import { NEWT_APP_UID } from '$env/static/private';
import { markdownToHtml } from '$lib/server/markdownToHtml';
export const load: PageServerLoad = async ({ params }) => {
const article = await newtClient.getFirstContent<Article>({
appUid: NEWT_APP_UID,
modelUid: 'article',
query: {
slug: params.slug,
select: ['_id', '_sys', 'title', 'slug', 'body', 'tags'],
body: {
// Markdown形式で取得する
fmt: 'text'
}
}
});
if (article === null) {
error(404, {
message: `記事(${params.slug})が見つかりませんでした`
});
}
// MarkdownをHTMLに変換
const contents = await markdownToHtml(article.body);
return {
article: {
...article,
body: contents
}
};
};
Markdownは、最終的にHTMLに変換して表示します。
Reactなどだと便利なライブラリが充実していますが、svelteだと対応プラグインが少なく、かなりフィジ検証に時間がかかってしまいました...。
検討の結果、remarkとrehyphプラグインを使用して変換するようにしました。
(この辺の検討内容は別の記事にまとめます)
import { unified } from 'unified';
import remarkLinkCard from 'remark-link-card-plus';
import markdown from 'remark-parse';
import remark2rehype from 'remark-rehype';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import html from 'rehype-stringify';
import rehypeCodeTitles from 'rehype-code-titles';
import rehypePrettyCode from 'rehype-pretty-code';
import { transformerCopyButton } from '@rehype-pretty/transformers';
import rehypeSlug from 'rehype-slug';
import rehypeToc from '@atomictech/rehype-toc';
import rehypeAutoLinkHeadings from 'rehype-autolink-headings';
export const markdownToHtml = async (
input: string,
{ toc = true }: { toc?: boolean } = {}
): Promise<string> => {
let processor = unified()
.use(markdown)
// リンクカードを生成
.use(remarkLinkCard, {
shortenUrl: true // リンクカード内のURLのホスト名のみを表示
})
.use(remarkGfm)
// 改行を有効化
.use(remarkBreaks)
.use(remark2rehype, { allowDangerousHtml: true })
.use(rehypeCodeTitles);
if (toc) {
processor = processor
.use(rehypeSlug)
// 目次を生成
.use(rehypeToc, {
// 今回は h1, h2 タグを対象とする
headings: ['h1', 'h2'],
cssClasses: {
toc: 'toc-container',
link: 'toc-link',
listItem: 'toc-list-item',
list: 'toc-list'
}
})
// アンカーリンクを生成する
.use(rehypeAutoLinkHeadings, {
behavior: 'append',
properties: {
className: ['anchor'],
ariaHidden: 'true'
},
content: {
type: 'element',
tagName: 'span',
properties: {
className: ['anchor-icon', 'ml-2', 'text-gray-400', 'hover:text-gray-600']
},
children: [{ type: 'text', value: '#' }]
}
});
}
processor = processor
// コードブロックのシンタックスハイライト
.use(rehypePrettyCode, {
transformers: [
transformerCopyButton({
visibility: 'always',
feedbackDuration: 3_000
})
]
})
.use(html, {
allowDangerousHtml: true
})
.process(input);
const { value } = await processor;
return value.toString();
};
まとめ
個人的な意見になりますが、初めてSvelteKitを使ってみてよかったなぁと思った点は以下の通りです。
- SvelteKitの直感的なルーティングとコンポーネント設計
- Markdownベースのコンテンツ管理の柔軟性
- 高速なHMRによる開発体験の良さ
- $propsシンタックスを使用することでクリーンなコードを書くことができる
- ドキュメントが見やすい
書き味はNextjsに近く、今回初めての採用だったのですが、ほとんど学習コストがかからなかったのが印象的でした。あとHMRが爆速で開発体験が良い!
一方で少しとまどった点を上げると、svelteそのものの進化が早く、開発中も記法の変更が何回かあったことでした(これは開発が盛んという意味では良いことなのかも?)
あと、初めてNewtを使ってみてよかったなと思った点は以下の通りです。
- 他のヘッドレスCMSと比較してAPI設定が直感的で分かりやすい
- ドキュメントが充実していて見やすい(主要なサービスとの連携方法が記載してある)
- なんといっても無料枠が大きい...!
無料で使える上に他の主要なヘッドレスCMSサービスに比べて、ブログに特化していて、直感的でわかりやすかったです。特殊な要件がない個人開発においては、第一候補としてオススメできるサービスだと思いました!
今後の展望
以下の機能を導入したいと思っています。
- キーワード検索(全文検索)機能の実装
- アクセス解析の導入
- ダークモードの導入