WordPressを高校生から使っていたのですが、メンテが面倒になったので、中学生ぶりに自作ブログシステムに切り替えました(退化????)
チョー簡単に書くと、PHPがMarkdownファイルを読んでHTMLにして返してるだけです。
日本のレンタルサーバー屋さんではPHPが(しか)バックエンドに使えることが多いためPHPを採用し、記事はDBに入れるよりMarkdownをgit管理したほうが移行も楽じゃね?と思ったのでMarkdownにしました。
せっかくなので中身を紹介します。
ざっくり全体像
PHP側で使ってるライブラリは4つ。
| ライブラリ | 役割 |
|---|---|
league/commonmark |
MarkdownをHTMLにする |
symfony/yaml |
front matterを読む |
symfony/cache |
変換結果をキャッシュする |
vlucas/phpdotenv |
設定を読む |
主にMarkdownファイルを読んで、ごにょごにょして記事の形にしてhtmlで返すようにしています。
URLの振り分け
Apacheの設定で、実在するファイル以外へのリクエストは全部 index.php に流してます。いわゆるフロントコントローラってやつですね。index.php は環境変数を読んで、セキュリティヘッダを付けて、あとは Router に丸投げします。
Router は URL の先頭を見て処理を選ぶだけの、if の羅列です。
$parts = explode('/', trim($route, '/'), 3);
$action = $parts[0] ?? '';
if ($action === '') {
$this->showPostList();
} elseif ($action === 'post' && isset($parts[1]) && $this->isValidSlug($parts[1])) {
$this->showPost($parts[1]);
} elseif ($action === 'category' && isset($parts[1]) && $this->isValidSlug($parts[1])) {
$this->showCategoryPosts($parts[1]);
} elseif (isset(self::STATIC_PAGES[$action])) {
$this->showStaticPage($action, self::STATIC_PAGES[$action]);
} elseif ($action === 'sitemap.xml') {
$this->showSitemap();
} else {
$this->show404();
}
| URL | 中身 |
|---|---|
/ |
記事一覧(ページネーションあり) |
/post/{slug} |
記事詳細 |
/category/{slug} |
カテゴリ別の一覧 |
/tag/{slug} |
タグ別の一覧 |
/archive |
年月別アーカイブ |
/about /contact など |
固定ページ |
/sitemap.xml /feed.rss /llms.txt /llms-full.txt |
機械向けの出力 |
正規表現でルーティングする仕組みは入れてません。ルートが10個ちょっとしかないので。ここに載ってないURLは全部404に落とします。
MarkdownをHTMLにする
記事ファイルは、こういう形をしてます。
---
title: "本ブログのシステムをご紹介"
date: "2026-08-19"
slug: "blog-system"
category: tech
tags:
- php
published: true
---
ここから本文。
--- で挟まれた上の部分が front matter で、記事のメタ情報です。ここは symfony/yaml に投げてパースしてます。
// 先頭の --- ... --- を front matter、残りを本文として切り出す
if (!preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)$/s', $content, $matches)) {
return null;
}
$frontMatter = Yaml::parse($matches[1]);
$markdownContent = $matches[2];
下の本文は league/commonmark に渡します。CommonMarkというのはMarkdownの仕様のひとつで、書き方が細かく決まってるやつです。ただそのままだとできないことも多いので、拡張をいくつか足してます。
- テーブル
- 打ち消し線
- 自動リンク(URLを書くだけでリンクになるやつ)
- 見出しへの id 付与
最後のやつは、見出しへ直接リンクするためのアンカーです。JavaScriptで後から振ることもできますが、それだとJSを実行しないクローラーからはアンカーが見えません。なのでサーバー側で振るようにしてます。
あと html_input は escape にしてあります。記事の中に生HTMLを書いてもタグとしては効かず、そのまま文字として表示されます。
$environment = new Environment([
'html_input' => 'escape',
'allow_unsafe_links' => false,
'heading_permalink' => [
'insert' => 'none', // ¶ みたいなリンク記号は出さない
'apply_id_to_heading' => true, // 見出しそのものに id を振る
'min_heading_level' => 2,
'max_heading_level' => 4,
],
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new HeadingPermalinkExtension());
$this->converter = new MarkdownConverter($environment);
キャッシュ
毎回ファイルを読んでパースして変換してたら遅いです。なので変換結果は symfony/cache でファイルに置いてます。
記事を更新するたびに手でキャッシュを消すのは面倒なので、Markdownの更新時刻を見て、前回より新しければ勝手に捨てるようにしました。ファイルを上書きしたら次のアクセスで反映されます。
$currentModTime = $this->getDirectoryModTime($postsDir);
$item = $this->cache->getItem('content_last_modified');
$lastModTime = $item->isHit() ? $item->get() : 0;
if ($currentModTime > $lastModTime) {
$this->clearCache();
$item->set($currentModTime);
$item->expiresAfter(86400);
$this->cache->save($item);
}
このへんが静的サイトジェネレータとの違いです。ビルドを回さなくていい代わりに、リクエストのたびにPHPが働くことになります。記事が数千本まで増えたら考え直すことになりそうですが、今のところは平気です。
魔改造してるところ
CommonMarkの仕様から外した挙動が4つあります。
1. 改行がそのまま改行になる
普通のMarkdownは1回の改行を無視するので、エディタで行を分けても表示は繋がった1段落になります。でもブログを書いてると、改行したところで改行してほしいじゃないですか。なので改行はそのまま出すようにしました。
$environment = new Environment([
'renderer' => [
'soft_break' => "<br>\n", // 単一改行を <br> に変換
],
]);
2. 段落の切れ目も改行
変換後のHTMLに出てくる </p><p> を、改行2つに置き換えてます。昔のブログみたいに1文ごとに行を空けて書くと、そのままスカスカの見た目になります。
$html = $this->converter->convert($markdownContent)->getContent();
// 段落の区切りを <br><br> に変換(空行を改行として表示)
$html = preg_replace('#</p>\s*<p>#', '<br><br>', $html);
3. 画像はファイル名だけでいい
 みたいにファイル名だけ書けば、画像置き場のパスを勝手に補ってくれます。毎回フルパスを打つのが面倒だったので。絶対URLを書けばそっちが優先されます。
//  を拾って、相対パスだけ書き換える
return preg_replace_callback(
'/!\[([^\]]*)\]\(([^)"\s]+)(\s+"[^"]*")?\)/',
function ($matches) {
[$whole, $alt, $path] = $matches;
// 絶対URL・絶対パス・data:URI はそのまま
if (preg_match('#^(https?://|/|data:)#', $path)) {
return $whole;
}
return "";
},
$markdown
);
4. img に lazy と実寸が付く
本文の <img> には loading="lazy" と decoding="async" を自動で足してます。さらにローカルの画像なら実際のサイズを読んで width と height も入れてます。これがないと画像が読み込まれた瞬間にレイアウトがガタッとずれるので。
if (!preg_match('/\bloading=/i', $tag)) {
$tag = str_ireplace('<img', '<img loading="lazy"', $tag);
}
if (!preg_match('/\bdecoding=/i', $tag)) {
$tag = str_ireplace('<img', '<img decoding="async"', $tag);
}
// ローカル画像は実寸を width/height に反映
if (!preg_match('/\bwidth=/i', $tag)
&& preg_match('/src=["\'](\/[^"\']+)["\']/', $tag, $src)) {
$size = @getimagesize(realpath($publicDir . $src[1]));
if ($size !== false) {
$tag = preg_replace(
'/<img/i',
'<img width="' . $size[0] . '" height="' . $size[1] . '"',
$tag,
1
);
}
}
結果としてこうなります。
<img loading="lazy" decoding="async" width="800" height="450" src="/images/posts/neko.jpg" alt="ネコ">
書きやすさに全振りした結果、標準のMarkdownとしては正しくないファイルが量産されてるわけです。他のツールに持っていったら表示が崩れると思います。まあ、書いてるの自分だけだしいいかな、ということにしてます。
フロントの話
テンプレート
テンプレートエンジンは入れてません。素のPHPです。layout.php が全体の枠で、その中に記事一覧なり記事詳細なりのビューを require してます。ヘッダーとサイドバーとフッターは共通。
テンプレートに変数を直書きせず、必ずエスケープ用のヘルパーを通すルールにしてます。例外は記事本文のHTMLだけで、こっちは変換の段階で html_input: escape を通してあるので、そのまま出してます。
<h1 class="post-title"><?= Helpers::e($post['title']) ?></h1>
<div class="post-content" data-toc="<?= ($post['toc'] ?? true) ? 'true' : 'false' ?>">
<?= $post['content'] ?> <!-- ここだけ生。変換時にエスケープ済み -->
</div>
素のPHPなので、テンプレートの中でも普通にPHPが書けてしまいます。書けてしまうぶん、ロジックを持ち込まないように気をつける必要はあります。
ダークモード
配色はCSS変数を1組だけ定義して、ダークテーマで上書きしてます。
:root {
--bg-body: #e0f2f7;
--bg-content: #ffffff;
--text-primary: #1a1a1a;
--accent: #2563eb;
}
[data-theme="dark"] {
--bg-body: #0a1929;
--bg-content: #1e293b;
--text-primary: #e5e7eb;
--accent: #3b82f6;
}
あとは html に data-theme="dark" が付くかどうかで全部切り替わります。
ここでハマりどころがあって、テーマの適用をページ下部のJSでやると、一瞬ライトテーマが見えてからダークに切り替わります。いわゆるFOUCというやつで、地味に目に痛い。
なので <head> の中に小さいインラインスクリプトを置いて、CSSが効く前に localStorage の値を html に反映させてます。
<head>
<meta charset="UTF-8">
<script>
// 描画前にテーマを適用してダークモードのちらつき(FOUC)を防ぐ
(function () {
try {
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('theme') || 'light'
);
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/css/style.min.css">
</head>
ペンダントライト
テーマの切り替えボタン、実はヘッダーからぶら下がってる照明です。
CSSで笠と上下のコードと引き手を組んであります。点いてるときは笠そのものがオレンジ色に光って、まわりにぼんやり光がにじみます。クリックすると消えて、ページの色が全部反転する。押してる間は紐がちょっと伸びます。
<div id="theme-toggle" class="pendant-light" onclick="toggleTheme()">
<div class="light-cord-top"></div>
<div class="light-shade"></div>
<div class="light-cord-bottom"></div>
<div class="cord-pull"></div>
<div class="light-glow"></div>
</div>
/* 点灯時は笠が光る */
.pendant-light.on .light-shade {
background: #fbbf24;
box-shadow: 0 0 15px rgba(251, 191, 36, 0.6),
0 0 30px rgba(251, 191, 36, 0.3);
}
/* 押してる間だけ紐が伸びる */
.pendant-light:active .light-cord-bottom {
height: 16px;
}
こういうワンポイントは絶対足したい!ってのが中学生の時からの名残ですねー(笑)
JavaScript
JSは3本だけです。theme.js がテーマの切り替えとスマホのハンバーガーメニュー、toc.js が目次の自動生成、share.js がシェアボタンのURLコピー。toc.js は記事の中の h2 と h3 を拾って目次を組み立てて、本文の先頭に差し込んでます。スクロールに追従して現在位置をハイライトする処理も入ってますが、そのままだとスクロールのたびに走って重いので、ブラウザの描画タイミングに合わせて間引いてます。
const headings = postContent.querySelectorAll('h2, h3');
headings.forEach((heading, index) => {
// サーバー側で出力された id があればそれを使う(連番はフォールバック)
const id = heading.id || 'heading-' + index;
heading.id = id;
// ...リンクを作って目次に足す
});
let ticking = false;
window.addEventListener('scroll', function () {
if (ticking) return;
window.requestAnimationFrame(function () {
updateActiveLink();
ticking = false;
});
ticking = true;
});
front matter に toc: false と書くと目次が出ません。短い記事だと邪魔なので。
X やはてブへのシェアボタンはただのリンクなので、JSは要りません。share.js がやってるのはURLのコピーだけです。
機械向けの出力
sitemap.xml、RSS、それと llms.txt。この3つもPHPがその場で組み立ててます。静的ファイルは置いてません。
記事を1本足したら、サイトマップにもRSSにも勝手に載ります。生成し忘れが起きないのが楽なところ。
llms.txt は最近出てきた仕様で、AIに読ませる用のサイト案内みたいなものです。記事の一覧をプレーンテキストで出してます。llms-full.txt の方は全文入り。効果があるかはよく分かってませんが、最近はググるよりAI君の引用のほうが増えてきているので付けておきました。
# マスクドドリーマー
> 仮面をこよなく愛する夢見る少年のブログ
日本語の個人ブログです。記事の全文は各 URL、または https://23blog.net/llms-full.txt で参照できます。
## 記事一覧
- [本ブログのシステムをご紹介](https://23blog.net/post/blog-system): このブログはPHPで自作しています。...
- [ブログ開始](https://23blog.net/post/newblog): どうも!ブラドール23です。本日よりまたブログを...
あとJSON-LDも吐いてます。これはページの内容を検索エンジンに機械可読な形で伝えるためのデータで、トップは WebSite、記事は BlogPosting、パンくずのあるページには BreadcrumbList を出してます。固定ページやエラーページには付けてません。
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "本ブログのシステムをご紹介",
"description": "このブログはPHPで自作しています。...",
"author": { "@type": "Person", "name": "マスクドドリーマー" },
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://23blog.net/post/blog-system"
},
"inLanguage": "ja",
"datePublished": "2026-08-19T00:00:00+09:00",
"dateModified": "2026-08-19T16:07:56+09:00",
"image": "https://23blog.net/images/og/og-default.jpg"
}
記事の書き方
ここまでが仕組みの話で、実際に書くときにやることはこれだけです。YYYY-MM-DD-slug.md という名前でファイルを作る。front matter を書く。本文を書く。以上。
---
title: "記事タイトル" # 必須
date: "2026-08-19" # 必須
slug: "article-slug" # 必須。URLになる
published: true # 必須。false なら公開されない
category: tech
tags:
- php
---
上の4つが必須で、あとは任意です。
| 項目 | 中身 |
|---|---|
excerpt |
一覧に出る抜粋。省略すると本文から作る |
category |
カテゴリ |
tags |
タグのリスト |
thumbnail |
アイキャッチ |
og_image |
SNSシェア用の画像 |
toc |
false で目次を消す |
published: false にしておけば、ファイルを置いたままでも公開されません。書きかけをそのまま放置できるので、これがいちばん助かってます。
画像は所定のフォルダーに置いて、本文からはファイル名だけで参照します。

まとめと所感
最近はCMSを使わずにAIに丸投げしてつくったであろう残念な静的ページサイトもメジャーになりつつありますが、
やっぱりブログのような更新やそのほか面倒をみてあげないといけないものを、中身のわからないまま抱えるのはしんどいですね。
かといってCMSも、中身がどうなってるかはよく分かりません。プラグインやテーマで何が足されてるのか、全部把握してる人ってどれくらいいるんでしょうか。
その点、自分で組んだやつなら全部わかるよねと。
好き勝手に管理しやすいように作ると楽しいし、愛着も沸いてブログの更新頻度もきっと増えますよね!()
というわけで、みなさんも作りましょう。
html + CSS でブログを作って、PHPをちょこっと足して、WordPressに移行して、現在と、本システムは4代目になるのかー!
ということはPHPを書いたのは高校生以来という事ですか。案外何とかなるもので、、、
相変わらず怠惰ですけど、自分だけの発信メディアを持てるブログっていいですね!