All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Keyboard shortcuts in post editor —
Ctrl/Cmd+Ssaves (draft or update depending on post status);Ctrl/Cmd+Shift+Ppublishes; shortcuts work both when the Markdown editor has focus (registered via CodeMirror keymap) and when any other field is active (registered viadocumentkeydown) - Real-time slug uniqueness check — the slug field in the post and page editors now shows an inline ✓ / ✗ indicator after a 350 ms debounce; resolved via a new session-authenticated
admin/slug-check.phpendpoint; correctly treats the current record's own slug as available when editing
- Tag autocomplete — the tag input in the post editor is now a pill-style picker; typing filters existing tags in a dropdown (keyboard navigable with ↑↓/Enter/Escape); new tags not in the list are still created on Enter or comma; existing tags are injected server-side as
window._existingTagsand never fetched asynchronously
- Named query placeholders — all
?positional placeholders insrc/andadmin/standardised to:namestyle (src/Post.php,src/Builder.php,admin/post-edit.php,admin/tags.php,admin/categories.php,admin/xmlrpc.php); dynamicIN (…)batch queries retain?as PDO has no named equivalent for variadic lists
- Typography — Inter — replaced Figtree (sans-serif) and Crimson Pro (serif) with Inter as the sole typeface; Inter is self-hosted as a variable font (
Inter-Variable.woff2/Inter-Variable-Italic.woff2, OFL license) covering weight 100–900; prose content switches from serif to sans-serif at1.1rem - OG image font —
src/OgImage.phpupdated to useInter-Regular.ttf/Inter-Bold.ttffor server-side PNG generation; existing OG images regenerate automatically on next build
- Custom CSS XSS fix —
</style>escape intemplates/base.phpchanged from case-sensitivestr_replacetostr_ireplace; previously a payload using uppercase</STYLE>bypassed the filter and could break out of the style block on every public page
- API CORS restricted —
Access-Control-Allow-Origin: *replaced with an origin-matched header derived from the configuredsite_url; falls back to*only whensite_urlis unset (initial setup);Vary: Originadded alongside; native app clients (iOS, Xcode simulator) are unaffected as they do not sendOriginheaders - CSP
img-srcbroadened — changed fromhttps://avatars.webmention.iotohttps:across all Nginx configs so external images embedded in post content (Markdown or raw HTML) are not silently blocked by the policy - Nginx
/fonts/location hardened — added explicit CSP (default-src 'none'; font-src 'self') and security headers to the/fonts/location indocker/nginx.conf, syncing it withnginx.conf.example
- Post date slug — posts published in the late evening (in negative-UTC-offset timezones) no longer get a slug one day ahead;
datePath()now converts the stored UTC timestamp to the configured site timezone before extracting theYYYY/MM/DDpath segment
- Code copy button — button now turns green immediately on click and stays green (with white checkmark) regardless of mouse position until the 2-second reset; previously the green state was only visible after mousing away from the button
- WordPress XML export — new Admin → Export page downloads all posts (published, and optionally drafts/scheduled) as a WXR 1.2 file; includes categories, tags, and post content rendered to HTML; importable into any WordPress site via Tools → Import
- Analytics — 404 errors now sorted by most recent first; default date range changed from 30 to 7 days; "Top pages" and "Device types" panels stack vertically on mobile; "Last seen" column is right-aligned
- Code simplification — migration loop, settings helpers, query patterns, slug generation, syndication logic, and build calls consolidated for clarity and consistency
- Standardised output escaping —
dashboard.phpandanalytics.phpnow useHelpers::e()consistently with the rest of the admin - page-edit delete handler — moved before save block so delete action is reachable (was previously unreachable)
- XSS —
$post->statusand$page->statusnow escaped withHelpers::e()in badge output - Session cookie
secureflag — now also set when behind a TLS-terminating reverse proxy viaHTTP_X_FORWARDED_PROTO - WebAuthn rpId — derived from canonical
site_urlsetting instead of attacker-controllableHTTP_HOSTheader - Migration seed SQL injection — settings seed now uses a prepared statement instead of string interpolation
- DNS rebinding (Mastodon SSRF) — hostname resolved immediately before curl connects and pinned via
CURLOPT_RESOLVE
- Analytics timezone — daily chart grouping, chart axis labels, and 404 "last seen" timestamps now respect the timezone configured in Settings instead of always using UTC
- Built-in analytics beacon —
track.phpat the web root acceptsnavigator.sendBeaconPOST requests with{url, referrer, is404}JSON; no cookies or third-party services used page_viewsdatabase table (schema v13) — stores url, referrer, device_type, is_404, ip_hash, and timestamp; auto-migrates on boot- IP privacy — client IP addresses are stored as HMAC-SHA256 hashes using a server-side salt; raw IPs are never persisted
- Rate limiting — PHP-level limit of 30 requests/minute per IP in
track.php; Nginxlimit_req_zoneat 2 r/s with burst 20 inlocation = /track.php - Analytics dashboard (
admin/analytics.php) — Chart.js graphs for views/day, top pages, device breakdown, and referrers; 404 error table; 7/30/90-day range selector; owner opt-out URL shown - Chart.js 4.4.7 vendored locally at
admin/assets/chart.min.js— no external CDN dependency - Beacon JS in public templates —
templates/base.phpnow includes a small inline script that firessendBeaconon page load; visit/?ti=excludeto set a localStorage opt-out flag,/?ti=includeto re-enable - 404 tracking —
templates/404.phpsets ananalyticsIs404flag so 404 hits are recorded separately in the dashboard - Automatic data pruning —
admin/bootstrap.phpdeletespage_viewsrows older than 90 days on ~1% of admin requests - Nginx config for beacon —
docker/nginx.confandnginx.conf.exampleboth include alocation = /track.phpblock withlimit_req,limit_except POST,client_max_body_size 4k, and security headers
- Updated
league/commonmarkto v2.8.2
- Passkey (WebAuthn) authentication — admin login now supports passkeys as an alternative to password + TOTP; manage passkeys from Admin → Account
- Static-output CMS with PHP/SQLite admin panel
- Markdown editor (EasyMDE) with GitHub-flavored Markdown, footnotes, and server-side syntax highlighting
- Posts and pages with draft, published, and scheduled statuses
- Date-based post URLs (
/YYYY/MM/DD/{slug}/) - Categories and tags taxonomy with archive pages
- Media library with drag-and-drop uploads
- Image galleries with masonry layout and lightbox
- Atom feed and JSON Feed 1.1
- Open Graph image generation (GD + FreeType)
- JSON-LD structured data (BlogPosting schema.org)
- Mastodon and Bluesky auto-syndication
- Incoming webmentions via webmention.io (client-side display)
- Outgoing webmentions CLI script (
bin/send-webmentions.php) - WordPress-compatible XML-RPC API (MarsEdit support)
- REST API with HTTP Basic Auth
- Client-side full-text search
- TOTP two-factor authentication
- Activity log and login attempt history
- Google Analytics GA4 integration (optional)
- Tinylytics integration (optional)
- Dark/light mode with system-preference detection
- Custom CSS via Settings panel
- Collapsible admin sidebar
- Docker local development setup
- Production Nginx configuration example with CSP headers and TLS