A lightweight, static-output CMS written in PHP, inspired by Kirby. Content is authored in Markdown through a secure admin panel and published as pre-rendered HTML files on disk. The server never runs PHP at page-browse time — only during admin operations and builds.
Key characteristics:
- PHP admin panel + build engine; pure HTML output for visitors
- SQLite for content metadata; Markdown source stored in the database
- Smart incremental rebuilds — only changed content re-renders
- Deployed on a Linux VPS (Digital Ocean / Hetzner); Nginx + PHP-FPM
- Single admin user (credentials in config file)
- Posts and static pages, both authored in Markdown
- Media uploads: images, video, audio
- Minimalistic one-column theme
- Atom feed, paginated post index, draft/scheduled posts
| Concern | Choice | Rationale |
|---|---|---|
| Language | PHP 8.3 | Current stable; ships in Ubuntu 24.04 PPA |
| Web server | Nginx 1.24+ | Efficient static file serving; PHP-FPM for admin |
| PHP process | PHP-FPM 8.3 | Standard Nginx/PHP integration |
| Database | SQLite 3 (via PDO) | Zero-config, file-based, no server needed |
| TLS | Let's Encrypt + Certbot | Free, auto-renewing certificates |
| Markdown | league/commonmark |
CommonMark compliant, actively maintained |
| Syntax highlighting | scrivo/highlight.php |
Server-side, no JS; xcode-dark palette |
| Admin editor | EasyMDE (SimpleMDE fork) | Browser-based Markdown editor, no build step |
| Dependency management | Composer | Standard for PHP |
| Templating | Plain PHP templates | No extra engine, easy to customise |
| CSS (admin) | Vanilla CSS | No framework, minimal footprint |
| CSS (theme) | Vanilla CSS | Output pages need zero JavaScript |
/var/www/cms/ ← document root on VPS (owned by deploy user, readable by www-data)
│
├── admin/ ← Admin panel (PHP, password-protected)
│ ├── index.php ← Login page / dashboard redirect
│ ├── dashboard.php
│ ├── posts.php ← Post list with status tabs and title search
│ ├── post-edit.php ← Create / edit post
│ ├── pages.php ← Static page list
│ ├── page-edit.php ← Create / edit static page
│ ├── media.php ← Media library & uploader
│ ├── settings.php ← Site-wide settings
│ ├── account.php ← Change admin password + TOTP 2FA management
│ ├── analytics.php ← Analytics dashboard (views/day, top pages, devices, referrers, 404s)
│ ├── api.php ← REST API endpoint (HTTP Basic Auth; posts, pages, media, categories, tags, settings)
│ ├── xmlrpc.php ← WordPress + MetaWeblog XML-RPC API endpoint
│ ├── login-log.php ← Activity log + login attempts viewer
│ └── assets/
│ ├── admin.css
│ ├── admin.js
│ ├── media.js
│ ├── chart.min.js ← Chart.js 4.4.7 (vendored)
│ ├── easymde.min.* ← Markdown editor (vendored)
│ ├── font-awesome.min.css
│ └── fonts/ ← Font Awesome icon fonts (self-hosted)
│
├── content/ ← BLOCKED: Nginx denies all access
│ └── media/ ← Uploaded files (images, video, audio)
│
├── data/ ← BLOCKED: Nginx denies all access
│ └── cms.db ← SQLite database
│
├── fonts/ ← Public: Figtree + Crimson Pro WOFF2 files
│ └── og/ ← OG image fonts (Figtree-Regular/Bold.ttf)
│
├── src/ ← BLOCKED: Nginx denies all access
│ ├── ActivityLog.php ← Admin activity logger (writes to activity_log table)
│ ├── Auth.php
│ ├── Bluesky.php ← Bluesky AT Protocol API client
│ ├── Builder.php
│ ├── Database.php
│ ├── Feed.php ← Atom 1.0 feed generator
│ ├── Helpers.php
│ ├── HighlightFencedCodeRenderer.php ← league/commonmark renderer for syntax highlighting
│ ├── ImageRenderer.php ← league/commonmark renderer: lazy load, WebP <picture>, dimensions
│ ├── JsonFeed.php ← JSON Feed 1.1 generator
│ ├── Mastodon.php ← Mastodon API client
│ ├── Media.php
│ ├── OgImage.php ← GD-based OG image generator
│ ├── Page.php
│ ├── Post.php
│ ├── Webmention.php ← Outgoing webmention discovery and sending
│ └── XmlRpc.php ← XML-RPC request parser and response encoder
│
├── templates/ ← BLOCKED: Nginx denies all access
│ ├── 404.php ← 404 Not Found error page
│ ├── base.php ← Shared HTML shell
│ ├── index.php ← Post listing / pagination
│ ├── page.php ← Static page
│ ├── post.php ← Single post
│ ├── search.php ← Client-side search page
│ └── taxonomy.php ← Category / tag archive listing
│
├── vendor/ ← BLOCKED: Nginx denies all access
│
├── media/ ← PUBLIC — served via Nginx alias to content/media/
│
├── posts/ ← Generated: date-based subdirectory per post
│ └── YYYY/MM/DD/{slug}/
│ ├── index.html
│ └── og.png
│
├── pages/ ← Generated: one subdir per static page
│ └── {slug}/
│ └── index.html ← served at /{slug}/ via Nginx @page fallback
│
├── page/ ← Generated: paginated index
│ └── 2/
│ └── index.html
│
├── search/ ← Generated: client-side search page
│ └── index.html
│
├── index.html ← Generated: post listing page 1
├── search.json ← Generated: search index (title, excerpt, date, URL)
├── feed.xml ← Generated: Atom 1.0 feed
├── theme.css ← Public stylesheet
├── theme.min.css ← Auto-generated minified CSS (not committed)
│
├── config.php ← BLOCKED: Nginx denies access; site config + admin credentials
├── composer.json
├── composer.lock
├── Dockerfile
├── docker-compose.yml
├── favicon.svg ← SVG favicon (blue rounded square, served publicly)
├── nginx.conf.example ← Production Nginx template
├── track.php ← Analytics beacon endpoint (public, POST only)
└── INSTALL.md
Note on
media/: Uploaded files live incontent/media/(blocked from web). Nginx'saliasdirective maps/media/requests directly tocontent/media/inside the server block — no symlinks or rewrites needed.
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL, -- Markdown source
excerpt TEXT, -- Optional hand-written summary
status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
published_at DATETIME, -- Actual or scheduled publish time
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
built_at DATETIME, -- Last time HTML was generated
content_hash TEXT, -- SHA-256 of rendered HTML; change detection
og_image_hash TEXT, -- Hash used to cache OG image generation
tooted_at DATETIME, -- Set when post is syndicated to Mastodon
mastodon_url TEXT, -- Canonical URL of the Mastodon toot
mastodon_skip INTEGER DEFAULT 0, -- 1 = skip Mastodon syndication
bluesky_at DATETIME, -- Set when post is syndicated to Bluesky
bluesky_url TEXT, -- Canonical URL of the Bluesky post
bluesky_skip INTEGER DEFAULT 0 -- 1 = skip Bluesky syndication
);CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL, -- Markdown source
nav_order INTEGER DEFAULT 0, -- Position in navigation
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
built_at DATETIME,
content_hash TEXT
);CREATE TABLE media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL, -- Stored filename (possibly renamed)
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL, -- Bytes
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Keys include: site_title, site_description, site_url, footer_text,
-- posts_per_page, feed_post_count, locale, timezone,
-- mastodon_handle, mastodon_instance, mastodon_token,
-- bluesky_handle, bluesky_url, bluesky_app_password,
-- github_url,
-- webmention_domain,
-- ga_measurement_id,
-- tinylytics_code,
-- tinylytics_kudos_emojiCREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
success INTEGER DEFAULT 0
);
CREATE INDEX login_attempts_ip_time ON login_attempts(ip, attempted_at);CREATE TABLE activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, -- create | update | publish | unpublish | schedule |
-- delete | upload | settings | password | rebuild
object_type TEXT NOT NULL, -- post | page | media | settings | account | site
object_id INTEGER, -- DB id of the affected record (NULL for settings/account/site)
detail TEXT NOT NULL DEFAULT '', -- post title, filename, etc.
ip TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Pruned probabilistically (~1% of requests); entries older than 90 days are deleted.CREATE TABLE page_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
referrer TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT 'unknown', -- desktop | mobile | tablet | unknown
is_404 INTEGER NOT NULL DEFAULT 0,
ip_hash TEXT NOT NULL DEFAULT '', -- HMAC-SHA256 of client IP with server-side salt
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX page_views_timestamp ON page_views(timestamp);
-- Populated by track.php beacon (raw PDO, no autoloader).
-- Pruned to 90 days on ~1% of admin requests in bootstrap.php.<?php
return [
'admin' => [
'username' => 'admin',
'password_hash' => '', // bcrypt hash generated at setup
'session_name' => 'cms_session',
'session_lifetime' => 3600, // seconds
],
'paths' => [
'data' => __DIR__ . '/data',
'content' => __DIR__ . '/content',
'output' => __DIR__, // Web root is project root
'templates' => __DIR__ . '/templates',
],
'security' => [
'max_login_attempts' => 5,
'lockout_minutes' => 15,
],
];Thin PDO/SQLite wrapper. Handles:
- Connection and schema creation on first run
- Prepared statement helpers (
select,selectOne,insert,update,delete,exec) - Migration runner (versioned via
schema_versionkey insettings)
login(username, password)— checks credentials, enforces rate limit, issues sessionlogout()check()— redirects to login if session is invalidcsrfToken()/verifyCsrf(token)— per-session CSRF tokensstartSession()— sets cookie params (HttpOnly, Secure, SameSite=Strict) then calls session_start()flash()/getFlash()— session-based PRG flash messagesisLockedOut(ip)— rate-limit check used by both web and XML-RPC auth
Active-record-style models:
findAll(status),findBySlug(slug),findById(id)save(),delete()needsRebuild()— compares currentcontent_hashto what would be renderedPost::promoteScheduled(db)— promotes due scheduled posts to publishedPost::datePath(published_at, slug)— returnsYYYY/MM/DD/{slug}path segment
The rebuild engine. Core methods:
buildPost(post)— render one post toposts/YYYY/MM/DD/{slug}/index.htmlbuildPage(page)— render one static page topages/{slug}/index.htmlbuildIndex()— render all paginated index pages + search.jsonbuildFeed()— render Atom feedbuildOgImage(post)— generate OG PNG via GD+FreeTypebuildCss()— write theme.min.css from theme.cssbuildAll()— full site rebuildmigrateOldPostPaths()/migrateOldPagePaths()— clean up legacy output paths
Static XML-RPC parser and response encoder:
parseRequest(body)— SimpleXML parse →['method' => string, 'params' => array]encodeValue(value)— PHP → XML-RPC typed value stringencodeResponse(value)— wraps in<methodResponse><params>envelopeencodeFault(code, message)— wraps in<methodResponse><fault>envelopeisoDate(datetime)— UTC datetime →YYYYMMDDThh:mm:ssparseDate(iso, timezone)— MarsEdit ISO8601 → UTCY-m-d H:i:s
upload(file)— validates type/size, stores file, inserts DB recorddelete(id)— removes file and DB recordall()— list media for the library UI- Allowed MIME types: JPEG, PNG, GIF, WebP, SVG, MP4, WebM, MP3, OGG
- Max upload size: 50 MB; filenames:
{stem}_{8hex}.{canonical_ext}
Renders feed.xml (Atom 1.0) from the N most recently published posts, with optional Tinylytics pixel tracking per entry.
Renders feed.json (JSON Feed 1.1) from the N most recently published posts. Linked in <head> for feed reader discovery.
Custom league/commonmark renderer for inline images. Adds:
loading="lazy"anddecoding="async"on every imagewidth/heightattributes (prevents CLS) for local/media/images<picture>+<source type="image/webp">wrapping when a.webpsibling exists for JPEG/PNG uploads
External images receive lazy/async only; no dimension enrichment or WebP wrapping.
Static utility class for outgoing webmention support:
extractUrls(html, siteUrl)— extract all external HTTP(S) links from post HTMLdiscoverEndpoint(targetUrl)— discover a webmention endpoint via HTTPLinkheader or HTML<link rel="webmention">sendPing(source, target, endpoint)— send a webmention POST and return success/failure
Used by bin/send-webmentions.php.
API clients for social syndication:
Mastodon::tootPost(title, excerpt, url)— posts a status via Mastodon API; returns?string(canonical toot URL on success,nullon failure)Bluesky::postToBluesky(title, excerpt, url)— posts via Bluesky AT Protocol; returns?string(canonicalbsky.appURL on success,nullon failure)- Returned URLs are stored on the post (
mastodon_url/bluesky_url) and displayed as "Also on:" links on the public post page
Generates 1200×630 PNG Open Graph images using GD + FreeType. Caches by og_image_hash stored on the post; regenerates only when title or site name changes.
log(action, objectType, objectId, detail)— inserts a row intoactivity_logwith the calling IP- Used by all admin POST handlers: post/page create, update, publish, unpublish, schedule, delete; media upload/delete; settings save; password change; manual site rebuild
- Instantiated as
$activityLoginadmin/bootstrap.php, available on every admin page
slugify(title)— URL-safe slug generationtruncate(html, length)— post excerpt fallbackformatDate(datetime, format, default, timezone)— timezone-aware date formattinge(string)—htmlspecialcharsshorthand
When a post or page is published or updated:
1. Render the item's HTML from current Markdown + template
2. Hash the rendered HTML
3. Compare to stored content_hash
4. If different:
a. Write file to disk
b. Update content_hash and built_at in DB
5. If post listing is affected (new post, post unpublished, slug changed):
a. Rebuild all paginated index pages + search.json
b. Rebuild feed.xml
Else if only content changed:
a. Rebuild feed.xml (excerpt or title may appear there)
b. Skip index pages (order and count unchanged)
Scheduled posts: On every admin page load, a lightweight check queries:
SELECT id FROM posts
WHERE status = 'scheduled' AND published_at <= CURRENT_TIMESTAMPAny due posts are flipped to published and their rebuild is triggered automatically.
Manual rebuild: The admin dashboard has a "Rebuild entire site" button that calls Builder::buildAll(). Useful after theme changes.
Posts use date-based URLs: /YYYY/MM/DD/{slug}/ → posts/YYYY/MM/DD/{slug}/index.html
Pages use slug-based URLs: /{slug}/ → pages/{slug}/index.html via an Nginx @page named-location fallback.
The Nginx config uses two rewrites for the date-URL block:
- Asset files (og.png etc.):
rewrite "^/([0-9]{4}/[0-9]{2}/[0-9]{2}/.+\.[^/]+)$" /posts/$1 break; - Slug paths:
rewrite ^/(.+?)/?$ /posts/$1/index.html break;
| Area | Measure |
|---|---|
| Authentication | bcrypt password hash in config; session-based auth |
| Session | Regenerate session ID on login; HttpOnly + SameSite=Strict cookie; Secure flag on HTTPS |
| CSRF | Token in every admin form; verified on every POST |
| Brute-force | IP-based lockout after N failures (stored in SQLite); applies to XML-RPC auth too |
| File access | Nginx deny all location blocks on src/, data/, content/, templates/, vendor/, config.php |
| File upload | MIME type whitelist server-side; no executable extensions allowed; files stored outside web root in content/media/ |
| SQL injection | PDO prepared statements throughout |
| XSS | All admin output passed through htmlspecialchars(); league/commonmark default escaping |
| Path traversal | Media filenames sanitised with basename() before any filesystem operation |
| Error display | display_errors = Off in production; errors logged to file |
| CSP | Separate Content-Security-Policy for admin (allows unsafe-inline) and public pages (strict) |
- Composer setup, directory scaffold,
config.php -
Databaseclass + schema creation + migration runner -
Authclass (login, session check, CSRF helpers) - Admin login page + session guard middleware
- Nginx server block with security location blocks
-
PostandPagemodels -
Helpers::slugify(),Helpers::formatDate() - Admin: posts list, post editor (save draft, delete)
- Admin: pages list, page editor
- EasyMDE integration in editor
-
Mediaclass (upload, validate, delete, list) - Admin: media library UI
- Media insert helper in post/page editor sidebar
-
content/media/→ publicmedia/routing via Nginxalias
-
Builderclass: render post, page, index, feed - PHP templates:
base.php,post.php,page.php,index.php - Content-hash diffing (skip unchanged files)
- Pagination logic in index build
- Build triggered on publish/unpublish/settings save
-
Feedclass (Atom 1.0 XML)
- Publish now, save draft, schedule (date picker), unpublish
- Scheduled post check on admin page load
- Status badges and filter tabs on posts list
-
base.phplayout with header, nav, footer - Single post template with Open Graph meta
- Static page template
- Index/listing template with pagination
- Responsive CSS (single column; Figtree UI + Crimson Pro prose typefaces)
- Settings screen + DB-backed site config
- Dashboard with stats + "Rebuild site" button
- Login rate limiting using
login_attemptstable - Flash messages (PRG pattern)
- Setup script to hash initial admin password
- INSTALL.md (VPS guide, Nginx, PHP-FPM, Let's Encrypt, UFW, backups)
-
nginx.conf.examplewith CSP headers, TLS placeholders - Dockerfile + docker-compose.yml for local dev
- Filesystem permissions documented
-
admin/account.php— change password (verifies current, 12-char minimum)
Features added after the initial build phases:
| Feature | Files |
|---|---|
Date-based post URLs (/YYYY/MM/DD/{slug}/) |
src/Post.php, src/Builder.php, nginx.conf.example |
| OG image generation | src/OgImage.php, src/Builder.php, fonts/og/ |
| Syntax highlighting | src/HighlightFencedCodeRenderer.php, theme.css |
Lazy image loading, WebP <picture>, CLS-safe dimensions |
src/ImageRenderer.php, src/Builder.php |
| Categories & Tags taxonomy | src/Post.php, src/Builder.php, src/Database.php (v9), templates/taxonomy.php, admin/categories.php, admin/tags.php, admin/post-edit.php, admin/xmlrpc.php |
| TOTP two-factor authentication | src/Auth.php, src/Database.php (v11), admin/index.php, admin/account.php |
| Mastodon auto-syndication | src/Mastodon.php, admin/post-edit.php, admin/settings.php |
| Bluesky auto-syndication | src/Bluesky.php, admin/post-edit.php, admin/settings.php |
| Syndication URL storage + display | src/Mastodon.php, src/Bluesky.php, src/Post.php, src/Database.php (v7), templates/post.php, theme.css |
| WordPress XML-RPC API | src/XmlRpc.php, admin/xmlrpc.php |
| REST API (HTTP Basic Auth) | admin/api.php |
| JSON Feed 1.1 | src/JsonFeed.php, src/Builder.php, templates/base.php |
| Outgoing webmentions (CLI) | src/Webmention.php, bin/send-webmentions.php |
| Client-side search | src/Builder.php (search.json), templates/search.php |
| Admin post search | admin/posts.php |
| Admin posts pagination | admin/posts.php |
| Shortcode embeds (YouTube, Vimeo, Gist, Mastodon, Instagram, X, LinkedIn) | src/Builder.php, admin/assets/admin.js, theme.css |
| Image galleries with lightbox | src/Builder.php, templates/post.php, theme.css, admin/assets/media.js |
| Custom CSS (Settings) | admin/settings.php, templates/base.php |
| Collapsible admin sidebar | admin/assets/admin.css, admin/assets/admin.js, admin/partials/nav.php |
| Tinylytics analytics + Kudos button | templates/base.php, templates/post.php, src/Feed.php, admin/settings.php, theme.css |
| Google Analytics (GA4) | templates/base.php, admin/settings.php |
| Webmention.io (incoming, client-side display) | templates/base.php, templates/post.php, admin/settings.php, theme.css |
| Microformats2 (h-entry) | templates/post.php, templates/index.php |
| JSON-LD structured data (BlogPosting) | templates/post.php |
| Reading time estimate | templates/post.php |
| Favicon | favicon.svg, templates/base.php |
| Lightbox for inline post images | theme.css, templates/base.php |
| Dark / light mode toggle | theme.css, templates/base.php |
| 404 Not Found page template | templates/404.php |
| Probabilistic DB cleanup | admin/bootstrap.php |
| Self-hosted Font Awesome (admin) | admin/assets/font-awesome.min.css, admin/assets/fonts/ |
| Figtree + Crimson Pro public typefaces | fonts/, templates/base.php, theme.css |
| CSP + security headers | nginx.conf.example, docker/nginx.conf |
Pages at /pages/{slug}/ via Nginx |
src/Builder.php, nginx.conf.example |
theme.min.css auto-generation |
src/Builder.php, admin/bootstrap.php |
| Activity logging | src/ActivityLog.php, src/Database.php (v10), admin/bootstrap.php, admin/post-edit.php, admin/page-edit.php, admin/media.php, admin/settings.php, admin/account.php, admin/dashboard.php |
| Logs admin page (activity + login attempts) | admin/login-log.php |
| Built-in analytics beacon | track.php, src/Database.php (v13), templates/base.php, templates/404.php, docker/nginx.conf, nginx.conf.example |
| Analytics dashboard (views/day, top pages, devices, referrers, 404s) | admin/analytics.php, admin/assets/chart.min.js, admin/bootstrap.php |
{
"require": {
"php": ">=8.1",
"league/commonmark": "^2.4",
"scrivo/highlight.php": "^9.18",
"spomky-labs/otphp": "^11.0",
"bacon/bacon-qr-code": "^2.0"
}
}EasyMDE and Font Awesome are vendored as static assets in admin/assets/ (no npm build step).
spomky-labs/otphp provides RFC 6238 TOTP support for 2FA. bacon/bacon-qr-code generates the QR code shown during 2FA setup.
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 1 vCPU |
| RAM | 512 MB | 1 GB |
| Disk | 10 GB SSD | 25 GB SSD |
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| Extension | Package | Purpose |
|---|---|---|
pdo_sqlite |
php8.3-sqlite3 |
SQLite database |
fileinfo |
php8.3-fileinfo |
Upload MIME validation |
mbstring |
php8.3-mbstring |
Required by league/commonmark |
simplexml |
php8.3-xml |
XML-RPC API request parsing |
gd |
php8.3-gd |
OG image generation (requires FreeType) |
curl |
php8.3-curl |
Mastodon & Bluesky API calls |
intl |
php8.3-intl |
Locale-aware string handling |
session |
built-in | Admin sessions |
hash |
built-in | Content-hash diffing |
json |
built-in | Config/settings |
openssl |
built-in | Session security |
| Item | Status |
|---|---|
| Multi-user accounts and roles | Out of scope |
| Categories, tags, or any taxonomy | Implemented (post-v1) |
| Comments | Out of scope |
| Search | Implemented (client-side, post-v1) |
| Image resizing / thumbnail generation | Out of scope |
| CDN integration | Out of scope |
| Two-factor authentication (TOTP) | Implemented (post-v1) |
| Git-based content versioning | Out of scope |
| Social syndication (Mastodon, Bluesky) | Implemented (post-v1) |
| Remote publishing API (XML-RPC) | Implemented (post-v1) |
| REST API (HTTP Basic Auth) | Implemented (post-v1) |
| Outgoing webmentions | Implemented (post-v1) |