SvelteKitのコードを読む — lib/blog/index.ts編
このファイルはブログ記事の読み込みと変換を担当する。routes/配下からは直接呼ばれず、+page.server.ts経由で使われる。
ファイルの全体像
readMarkdownFiles() → src/content/blog/*.mdを全部読む(内部処理)
getPosts() → 記事一覧を返す(/blog ページで使う)
getPost(slug) → 記事1件をHTMLに変換して返す(/blog/[slug] ページで使う)
ブロック① インポート
import fs from 'node:fs' // ファイル読み書き(Node.js組み込み)
import path from 'node:path' // ファイルパス操作(Node.js組み込み)
import matter from 'gray-matter' // Markdownのメタデータ解析
import { unified } from 'unified' // Markdown→HTML変換パイプラインnode:プレフィックスは「Node.js組み込みモジュール」を明示する書き方。npm install不要。
ブロック② interface(型の設計図)
export interface BlogPost {
slug: string // URL用のID例: "hello-world"
title: string // 記事タイトル
description: string
date: string
published: boolean // 公開・非公開フラグ
tags: string[] // 文字列の配列
}
export interface BlogPostWithContent extends BlogPost {
html: string // BlogPostの全項目 + html だけ追加
}interfaceはTypeScript独自の機能で「このオブジェクトはこの形にしてね」という型の設計図。
extendsはFlutterのクラス継承と同じ概念:
// TypeScript
interface BlogPostWithContent extends BlogPost { ... }// Flutter(同じ概念)
class BlogPostWithContent extends BlogPost { ... }exportありとなしの違い
// export なし → このファイルの中だけで使える(内部処理)
function readMarkdownFiles() { ... }
// export あり → 他のファイルからimportして使える(外部公開)
export function getPosts() { ... }
export function getPost() { ... }部屋に例えると:
lib/blog/index.ts という部屋
export なし readMarkdownFiles() → 部屋の中だけで使う道具(外には出せない)
export あり getPosts() → 窓口に出して他の部屋から使える
export あり getPost() → 窓口に出して他の部屋から使える
実際に+page.server.tsではexportありの関数だけimportできる:
import { getPosts } from '$lib/blog' // ← exportありだからimportできる
// readMarkdownFilesはimportできない ← exportなしだからブロック③ readMarkdownFiles() — チェーン処理
function readMarkdownFiles(): { slug: string; raw: string }[] {
if (!fs.existsSync(BLOG_DIR)) return [] // フォルダがなければ空配列を返す
return fs
.readdirSync(BLOG_DIR) // フォルダ内のファイル名一覧を取得
.filter((f) => f.endsWith('.md')) // .mdファイルだけに絞る
.map((f) => ({ // 各ファイルを変換
slug: f.replace('.md', ''), // "hello-world.md" → "hello-world"
raw: fs.readFileSync(path.join(BLOG_DIR, f), 'utf-8'),
}))
}.filter().map()は配列のチェーン処理。Dartのwhere().map()と同じ:
// Flutter(同じ概念)
files
.where((f) => f.endsWith('.md'))
.map((f) => {'slug': f.replaceAll('.md', ''), 'raw': readFile(f)})ブロック④ getPosts() — Null合体演算子
export function getPosts(): BlogPost[] {
return readMarkdownFiles()
.map(({ slug, raw }) => {
const { data } = matter(raw) // メタデータを取り出す
if (!data.published) return null // published:falseなら除外
return {
slug,
title: data.title ?? slug, // titleがなければslugを代わりに使う
// ↑ Null合体演算子
} satisfies BlogPost
})
.filter((p): p is BlogPost => p !== null) // nullを除外
.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime() // 新しい順
)
}??(Null合体演算子)はDartと全く同じ記法:
// TypeScript
title: data.title ?? slug
// → data.titleがnull/undefinedならslugを使う
// Dart(同じ)
title = data['title'] ?? slug;ブロック⑤ getPost() — unifiedパイプライン
export async function getPost(slug: string): Promise<BlogPostWithContent | null> {
const filePath = path.join(BLOG_DIR, `${slug}.md`)
if (!fs.existsSync(filePath)) return null // ファイルがなければnull(404用)
const raw = fs.readFileSync(filePath, 'utf-8')
const { data, content } = matter(raw)
// data → メタデータ(title・date・tagsなど)
// content → 本文(---より下の部分)
const result = await unified()
.use(remarkParse) // Markdownをパース
.use(remarkRehype) // Markdown → HTML構造に変換
.use(rehypeSlug) // 見出しにidをつける(#リンク用)
.use(rehypeAutolinkHeadings) // 見出しをクリックできるリンクにする
.use(rehypePrettyCode, { theme: 'github-dark' }) // シンタックスハイライト
.use(rehypeStringify) // HTML文字列に変換
.process(content) // 実行
return { ...data, html: result.toString() }
}unified().use().use().process()はパイプライン処理。データを順番に加工していく:
Markdownテキスト
↓ remarkParse → 構造化データ(AST)に変換
↓ remarkRehype → HTML構造に変換
↓ rehypeSlug → 見出しにid付与
↓ rehypePrettyCode → コードに色付け
↓ rehypeStringify → HTML文字列に変換
完成したHTML
まとめ
| 概念 | 説明 |
|---|---|
interface |
オブジェクトの型の設計図 |
extends |
別のinterfaceを継承して項目を追加 |
exportあり |
他のファイルからimportできる |
exportなし |
そのファイル内でのみ使える |
?? |
左がnull/undefinedなら右を使う |
| チェーン処理 | .filter().map().sort()を繋げて書く |
| パイプライン | データを順番に加工していく処理 |