A markdown-first personal blog with CRT phosphor aesthetics, operator-flex gamification, and zero moving parts.
Static. Markdown. Plain PHP. Posts live as YYYY-MM-DD-slug.md
files on disk — no database, no framework, no build step. A few
thousand lines of plain PHP render them through a Caddy + php-fpm
stack you can deploy to a 5 USD VPS (or a Raspberry Pi) in under a
minute.
AI-friendly by design. Every post is also served as raw .md at
/posts/{slug}.md. The site publishes llms.txt
as a three-section index — ## Posts (each with a short summary),
## Series (multi-part collections with manifest descriptions), and
## Tags — so language models and tooling can ingest a sectioned
catalogue of the entire blog without scraping HTML. Valid RSS 2.0 too
— and an article:published_time that carries full ISO datetime
precision when you bother to set it.
Operator-flex /about page. A Duolingo-style streak flame tracks
your writing cadence — day, week, month, or year, you pick. A
JSON-driven badge catalogue at content/badges.json covers post-count
milestones, markdown-element patterns (image-rich, code-heavy,
link-curator), tag specialisation, time-of-day Easter eggs (night owl,
lunch break, Friday night transmission), calendar anniversaries, and
multi-year streaks — 13 reusable kinds, 30+ shipped entries, edit in
place. Hidden badges only appear in the HTML once unlocked, so the
surprise survives a "view source" peek.
Rich media in markdown.  (or a bare URL on its
own line) auto-renders as a <video> player — #bg opts into
Hermes-style autoplay loop muted ambient. Adjacent ![]() lines
collapse into a CSS Grid gallery (count-2 through count-6) with
title-attribute captions. YouTube URLs still auto-embed as iframes.
Series with halftone-dot covers. A multi-part series (any post
with series: my-slug in its frontmatter) shows up at /series and
/series/{slug} — and now carries a manifest sidecar
(content/series/{slug}/manifest.json) with editorial title +
description + cover image. Cover uploads are center-cropped to 600×600,
run through a deterministic 4×4 ordered-Bayer dither (built from
primitive Imagick composite + threshold ops so the output is bit-identical
across IM6/IM7/Alpine/brew, sidestepping the thresholds.xml lookup that
Ubuntu's apt build silently no-ops on), and saved as a
transparent-where-light WebP that CSS mask-image + currentColor
tints with the active theme — phosphor amber, green, crypt red,
violet p7, electric p11, whichever. No cover yet? /series cards still fall back
to the QR + greek-glyph stamp. The admin canvas at /admin/series
lists every discovered series with manifest + cover state and supports
editing metadata, renaming the slug (bulk-rewrites every matching
post's frontmatter atomically + moves content/series/{slug}/), and
attaching arbitrary posts to the series via native HTML5 datalist.
Social meta on /series/{slug} carries the cover as og:image so
Telegram / X / Slack link previews show the dithered art. The
post editor's series: field is now a <datalist> autocomplete of
every existing slug. ext-imagick is a soft dep — title + description
still save without it; only cover upload is gated. See
docs/writing-posts.md → "Series management".
Opt-in plugins. Drop a folder into plugins/{slug}/, set
PLUGINS=slug in .env, and the site picks up a new page, header
link, and (optionally) admin section — without touching core. Each
plugin is self-contained (manifest, PSR-4 source, views, route-scoped
CSS/JS, private storage under content/plugins/{slug}/) and gets a
type-safe App\Plugin interface to register public routes, nav links,
assets, and /admin/{slug}/* pages (auto-wrapped with
Auth::requireAuth()). Reserved core paths can't be claimed; a broken
plugin logs + skips without taking the site down; strict CSP stays
intact. Ships with plugins/hello-world/ as the reference,
plugins/graffiti/ for cross-blog sticker / spray-paint exchange
between LazyBlog friends (energy ledger, federated handshake,
magic-link cross-blog spray, symmetric revoke — see
plugins/graffiti/README.md), and plugins/stalk/ for a pull-only
LazyBlog feed reader at /stalk (operator pastes friend blog URLs,
plugin validates <generator>LazyBlog</generator> on /feed.xml, polls
every 3h/10h/1d, shows top-N newest posts per friend — see
plugins/stalk/README.md). Empty PLUGINS= is a no-op. See
docs/plugin-development.md.
Password-protected posts. Lock a single post behind a per-post
password without touching the rest of the editor flow. Set it in the
admin editor with one click ([ Set Password ] / [ Remove Password ]
side-channel buttons — no save-post round trip); the password is
bcrypt-hashed into the YAML frontmatter as password_hash:. Visitors
land on an "ACCESS RESTRICTED" HUD form (theme-colored, terminal
aesthetic) instead of the body; the correct password sets a session
flag and unlocks for the rest of that browser session. Wrong-password
state shakes the panel red and shows attempts-left; 10 failures in a
sliding 15-min window throttle the IP and disable the field
(TRUST_CF_CONNECTING_IP=true switches the IP source to the
CF-Connecting-IP header when traffic actually flows through
Cloudflare). Listings prefix the title with a bare Font Awesome
padlock (fa-lock locked, fa-unlock-alt once the visitor has unlocked
this session) that tints with the active theme via currentColor, so
the operator can see at a glance which posts are gated. Set / Update
/ Remove all flash inline feedback above the form (// Password set.,
// Password updated., // Password removed.); the password field
validates >= 4 chars in the browser before any round trip. Raw .md,
/llms.txt, and /feed.xml exclude protected posts
entirely (anonymous 404 on the .md endpoint;
unlocked-session readers and admins get the markdown back with the
password_hash: line stripped). Search still surfaces title + tag
matches with a // protected post snippet but never indexes the body.
See docs/writing-posts.md → "Password-protected posts".
Hardened, ergonomic. CSP (incl. media-src), CSRF, atomic file
writes, session hardening, scheme-whitelisted image URLs. Stylesheets
split by concern and cache-busted via filemtime so deploys
invalidate browser caches per-file without manual asset rotation.
Backup with rsync. Restore in seconds.
┌─────────────────────────────────────────────────────┐
│ CRT terminal layout · 6 phosphor themes │
│ Phosphor vignette · chromatic-aberration heads │
│ Markdown files · llms.txt + raw .md + RSS │
│ TOC + scrollspy · code-block copy buttons │
│ Browser admin UI · EasyMDE + server-side prev │
│ Mobile mini-toolbar · IME-safe phone editor │
│ Image column-width · duotone tint + hover-orig │
│ YouTube auto-embed · .webm/.mp4 → <video> │
│ Image gallery grid · caption via title-attr │
│ Plugins (opt-in) · drop folder, set PLUGINS= │
│ Series with covers · Bayer-dither WebP + manifest│
│ Password-protected · bcrypt + session unlock HUD │
│ Search + Archive · reading-progress meter │
│ SEO + JSON-LD · Open Graph + Twitter Card │
│ CSP + session hard. · CSRF + atomic file writes │
│ Writing streak card · JSON-driven badge catalog │
│ GFM tables · admonitions + freq-tags │
└─────────────────────────────────────────────────────┘
amber (default) · green · crypt red · brutalist mono · p7 violet · p11 electric blue — all swappable from the in-page theme picker.
- PHP 8.2+ (no framework)
- Composer:
league/commonmark,symfony/yaml,vlucas/phpdotenv,chillerlan/php-qrcode - ext-imagick (optional — required only for series cover dither)
- Caddy + php-fpm
- Fonts via Google Fonts: VT323, Share Tech Mono, Play
- Docker Compose for local dev
Local dev (Docker):
cp .env.example .env
docker compose up -d --build
docker compose exec app composer install
open http://localhost:8080Local dev (no Docker):
cp .env.example .env
composer install
php -S localhost:8000 -t publicProduction VPS (one-shot bare-metal install):
# On Debian/Ubuntu/Raspbian (x86_64 or ARM) — run as root:
curl -fsSL https://raw.githubusercontent.com/hieuha/LazyBlog/refs/heads/main/scripts/install-vps.sh | sudo bash(Interactive prompts read from /dev/tty so the curl-pipe pattern keeps
working. Prefer to read the script first? curl -o install-vps.sh ... && sudo bash install-vps.sh.)
The installer pins PHP 8.2, creates an isolated lazyblog system user,
drops a Caddy site on :80, sets up a daily backup cron, and prompts
for admin password + site title + URL + author + callsign + timezone.
See docs/bare-metal-deployment-guide.md for the manual step-by-step
playbook.
| File | Read when |
|---|---|
docs/writing-posts.md |
Authoring posts — frontmatter, admin UI, drafts, password-protected posts |
docs/markdown-syntax.md |
What renders inside post .md files |
docs/configuration.md |
.env variables + URL route reference |
docs/bare-metal-deployment-guide.md |
Manual VPS playbook (what install-vps.sh automates) |
docs/docker-deployment-guide.md |
Docker workflow — dev compose + production image |
docs/backup-and-restore.md |
Backup script, cron, restore drill |
docs/seo-and-social.md |
OG tags, JSON-LD, llms.txt, RSS, how to test link previews |
docs/security.md |
CSP, session hardening, production checklist |
docs/system-architecture.md |
Request lifecycle, render pipeline, file layout |
docs/badges.md |
TX streak + customisable badge catalogue on /about |
docs/plugin-development.md |
Writing your own plugin — routes, nav, assets, admin, storage |
LazyBlog/
├── public/ # web root (front controller + assets)
│ └── assets/ # base/effects/components/post/pages.css + admin.css + admin-editor.js
├── src/ # PHP — Router, Controllers, PostRepository, MarkdownRenderer, Http...
│ ├── Badges/ # gamification: BadgeKinds (compute templates) + BadgeRegistry (loader)
│ ├── Plugin.php # plugin contract — implemented by each enabled plugin
│ ├── PluginManifest.php
│ ├── PluginContext.php # stable API surface plugins receive
│ ├── PluginRegistry.php # boot + dynamic PSR-4 + reserved-path collision check
│ └── Controllers/PluginAssetController.php # /plugin-assets/{slug}/{file}
├── views/ # layout, post, home, tag, admin/*
├── plugins/ # opt-in plugin folders — PLUGINS=slug in .env enables them
│ ├── hello-world/ # canonical reference plugin
│ ├── graffiti/ # cross-blog sticker exchange (federated, energy economy)
│ └── stalk/ # pull-only LazyBlog feed reader at /stalk (top-N latest per friend)
├── content/ # markdown posts + about + badges.json (mostly gitignored)
│ ├── posts/ # YYYY-MM-DD-slug.md
│ ├── about.md # /about page source
│ ├── badges.json # streak + badge catalogue (allowlisted in .gitignore)
│ └── plugins/ # plugin-private storage (gitignored, backed up via rsync)
├── scripts/
│ ├── install-vps.sh # one-shot bare-metal installer
│ ├── hash-password.php
│ └── backup-content.sh
├── tests/ # plain-PHP assertion fixtures (no PHPUnit)
│ ├── test-gamification.php # streak math
│ ├── test-plugin-system.php # plugin registry + manifest + asset matcher
│ ├── test-plugin-events.php # post.save event broadcast to plugins
│ ├── test-graffiti-*.php # graffiti plugin: boot, friends, inbox,
│ │ # energy, outbox, rate-limit, moderation, render
│ └── test-stalk-*.php # stalk plugin: friend-store, post-cache, config,
│ # feed-parser, refresh-service, boot
├── docs/ # detailed docs (see table above)
├── Caddyfile.example # production HTTPS site block
├── docker-compose.yml # dev: Caddy + php-fpm bind-mount
├── Dockerfile # dev image
├── Dockerfile.prod # production: non-root, opcache, no bind mount
├── composer.json
└── .env.example
For the full file-by-file breakdown see docs/system-architecture.md.
MIT.
