A blazingly fast, opinionated, native TypeScript CMS for agencies and small businesses. Designed for Perry AOT compilation. Runs on Node and Bun too.
Status: v0.1 (2026-05-20). Backend + HTMX admin + @skelpo/cms-client + @skelpo/site-kit + CLI all implemented and end-to-end verified; perry.land is the proven first sample case (see docs/perry-landing-integration.md).
License: MIT. Maintained by Skelpo GmbH.
- What is Skelpo CMS
- Philosophy
- Architecture
- Performance budget
- Data model
- Permissions
- Schema evolution
- Cache & invalidation
- SEO & agent optimization
- Customer frontend (the public site)
- Upgradability
- Multi-runtime support
- Feature scope
- v0.1 deliverables
- Repository layout
- References
Skelpo CMS is a content management system for the kind of websites most of the web actually consists of: agency homepages, small business sites, marketing sites, documentation portals, blogs. Not a platform, not a SaaS, not e-commerce, not a forum — those have their own better-suited tools (e.g. Medusa for commerce).
It is:
- Headless — backend API + admin UI only; the public website is a separate codebase owned by the customer.
- API-first — every admin action goes through the same REST API a mobile app or external integration would use.
- Opinionated — one rich-text editor, one set of field types, one set of email backends. We make the choices so the user doesn't have to.
- Blazingly fast — designed for Perry AOT compilation. Sub-2ms cached responses. 100K+ RPS on commodity hardware. <50ms cold start.
- Multi-runtime — runs on Perry (recommended), Node, or Bun. Same code, three artifacts.
- MySQL-backed — one database, no choice paralysis.
- Single-tenant — one deploy per site. No shared-state surprises. Multi-tenancy is out of scope.
- Upgrade-safe — the CMS binary and the customer's frontend binary upgrade independently. No file migrations. No theme merges. No
wp-content/dance.
It is not:
- A page builder with drag-drop block editing (TipTap rich text is the editor)
- A theme marketplace (no themes — the customer's frontend is the theme)
- A plugin platform with arbitrary code execution (custom content types + webhooks are the extension surface)
- An e-commerce platform (use Medusa)
- A membership/subscription system
- A multi-tenant SaaS
The opinionated stances, stated plainly. These are non-negotiable in v1; they're load-bearing for the design.
- MySQL only. No "supports MySQL/Postgres/SQLite." MySQL via
@perryts/mysql— a pure-TypeScript wire-protocol driver, zero native deps. Runs on Perry, Node, and Bun. AOT-compiles cleanly. - No themes inside the CMS. The customer's frontend is the theme. The CMS doesn't render public pages.
- No plugins. Custom content types + webhooks are the only extension surface. Arbitrary code execution is a security and upgrade nightmare we explicitly avoid.
- TipTap rich text. No Gutenberg blocks. No alternate editors. No raw HTML pasting (TipTap JSON only — keeps cache + render invariants safe).
- Meta description and image alt text are required. Publish is blocked without them. SEO + accessibility + LLM-friendliness aren't optional.
- Forms have a fixed set of 11 field types. That's all. Don't ask.
- Email sending is always async. No synchronous send. Failures retry. Submissions always persist regardless of mail success.
- One email backend at a time. Configure SMTP or Resend or Postmark or SES. No "fallback chains."
- Server-side analytics, no client JS. GDPR-compliant by default (anonymized IP hashes, no cookies, no PII).
- Single-tenant, single-binary CMS. One deploy per site. No multi-tenant mode.
- Performance is a CI gate. The perf budget below is enforced. PRs that break it fail.
- The CMS admin is uniform. Customers don't theme it. Editors get a consistent experience across all Skelpo sites.
- Editors never rebuild. Content, menus, settings, forms, redirects — all changes go live via webhook+cache, no recompilation.
- Developers always control structure. HTML, CSS, JS — fully owned by the customer's frontend codebase. Recompile + deploy on design changes.
- Native deps in core: zero. No Sharp, no native argon2, no tree-sitter, no
node-gyp. Image processing via imgproxy sidecar.
If you disagree with any of these, Skelpo CMS is probably not your tool. That's fine — pick one of the many other excellent CMSes.
Skelpo CMS is a two-binary system: a backend and a frontend. They communicate over HTTP.
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
│ skelpo-cms (backend) │ │ skelpo-site-<customer> (frontend)│
│ │ │ │
│ - REST API (/api/v1/*) │ │ - Customer-owned codebase │
│ - Admin UI (HTMX, /admin/*) │◄───┤ - Full HTML/CSS/JS control │
│ - Auth (sessions + bearer tokens) │ │ - Their own JSX templates │
│ - Media uploads + imgproxy signing │ │ - Their own design system │
│ - Forms + email + jobs │ │ - Perry-compiled (or Node/Bun) │
│ - Webhooks (outbound) │───►│ - Receives webhooks for live │
│ - Single Perry binary, ~15 MB │ │ cache invalidation │
└──────────────────────────────────────┘ └──────────────────────────────────┘
│ │
MySQL uses @skelpo/cms-client
media (S3/local + imgproxy) and @skelpo/site-kit
- Serves the REST API on
/api/v1/*— content, types, media, users, menus, forms, settings, webhooks, search, analytics - Serves the HTMX admin UI on
/admin/*— uniform across all Skelpo sites - Handles authentication (sessions for browser, bearer tokens for SDK/mobile)
- Runs background jobs (publish scheduled content, send emails, fire webhooks, regen sitemaps)
- Validates all writes; enforces required SEO/accessibility fields at publish time
- Never renders public-facing HTML
- Renders every public page with full HTML/CSS/JS control
- Calls the CMS API via
@skelpo/cms-clientto fetch content, menus, settings - Handles its own caching (the SDK provides this)
- Receives webhook notifications from the CMS on content changes → invalidates cache → next visitor sees fresh data
- Implements public routes (catchall pattern resolves any content URL → API → template)
- Owned and deployed by the customer; recompiled when design changes
| Artifact | Format | Target use |
|---|---|---|
| Perry binary | Single executable (~15 MB) | Recommended production |
| Docker image | skelpo/cms:1.x.y |
Container deploys |
| npm package | @skelpo/cms (CJS+ESM) |
Users on Node/Bun who want their existing runtime |
Same source code produces all three.
These are CI-gated. PRs that break them fail. Numbers are on Perry; Node/Bun are slower but still beat WordPress and Strapi.
| Metric | Target (Perry) | Target (Bun) | Target (Node) |
|---|---|---|---|
| Binary / package size | <20 MB | <50 MB | <100 MB |
| Cold start | <50 ms | <200 ms | <800 ms |
| Cached page TTFB (p99 local) | <2 ms | <5 ms | <10 ms |
| Uncached page TTFB (p99 local) | <10 ms | <30 ms | <60 ms |
| Memory idle RSS | <50 MB | <100 MB | <200 MB |
| Cached RPS (single instance) | 100K+ | 30-50K | 10-20K |
| DB queries per cached request | 0 | 0 | 0 |
| DB queries per uncached page | ≤3 | ≤3 | ≤3 |
| Cache hit ratio (public traffic) | >95% | >95% | >95% |
- Render at write, not at read. Published content is rendered into a compressed HTML buffer on publish; cached buffer is what's served on read.
- Compiled JSX templates. No template engine, no AST walking per request. (Customer's site benefits the same way via Perry compilation.)
- Brotli pre-compressed cache. Cached buffer is already wire bytes;
write(2)directly to socket. - Zero allocations on cache hit. Return a pointer to the cached buffer.
- Native syscalls.
sendfile(2)for static assets and cached HTML;splice-style zero-copy where possible (on Perry). - In-process cache with surgical dependency graph. No Redis required for single-tenant deploys.
- Single-binary horizontal scaling. <50ms cold start means autoscale-from-zero is real.
- One indexed query per content fetch. Custom fields in a JSON column; relations fetched in one second query if
?include=is used.
| WordPress | Strapi 5 | Directus | Payload v3 | Skelpo (Perry) | |
|---|---|---|---|---|---|
| Cold start | ~500 ms | ~2000 ms | ~3000 ms | ~3000 ms | <50 ms |
| RPS (cached) | ~500 | ~5K | ~5K | ~5K | >100K |
| RPS (uncached) | ~50 | ~1K | ~1K | ~1K | >10K |
| Memory idle | 100MB×N workers | ~300 MB | ~400 MB | ~500 MB | <50 MB |
| Native deps | PHP + ext | Node + 1000 npm | Node + 800 npm + Sharp | Node + Next + 1500 npm | just imgproxy |
| Static export built-in | No | No | No | No | Yes |
Identical Fastify source, same machine (M-series Mac), same harness
(autocannon, 50 conns × 20 s). Full writeup +
scripts/bench-twin/ reproducer in
docs/benchmarks-perry-vs-node.md;
deployed-CMS end-to-end numbers in docs/benchmarks.md.
| Axis | Node + tsx | Perry native | Δ |
|---|---|---|---|
| Cold start (spawn → 200) | 730 ms | 44 ms | ≈17× faster |
| RPS, /loop (CPU bound) | 49,947 | 67,197 | +35% |
| RPS, /json (1KB serialize) | 53,498 | 65,766 | +23% |
| RPS, /healthz (tiny JSON) | 58,522 | 65,723 | +12% |
| RSS, idle | 86 MB | 11 MB | ≈8× smaller |
| Distributable | ~105 MB (node + node_modules) | 3.5 MB binary | ≈30× smaller |
Responses are byte-identical (md5-verified). Throughput is +20% on average at this concurrency; the lopsided wins are cold start (≈17×), idle memory (≈8×), and deployable size (≈30×) — the axes that matter for autoscale-from-zero, FaaS, and edge/CLI shapes.
The full SQL schema lives at docs/schema.md (next deliverable). High-level overview:
contentTypes— type definitions including the JSONfieldsSchemacontentTypeRevisions— schema history, enables lazy migrationcontent— every piece of content (built-in types + custom), with JSONfields,seo,aicolumnscontentRelations— many-to-many relation linkscontentRevisions— content edit history per row
users— with bcryptjs password hashes + optional TOTProles— capability bundles (JSON)sessions— DB-backed sessions for admin browser
media— uploaded files (alt text per locale, focal points)menus+menuItems— drag-drop-buildable in adminsettings— flat key-value store (site.name,seo.organizationSchema, etc.)redirects— 301/302 management (critical for SEO when URLs change)emailTemplates— editable templates with variable interpolationformSubmissions— every form submission persists, regardless of email successjobs— DB-backed background queue (sendEmail, preRender, webhookDispatch, regenSitemap, etc.)webhooks+webhookDeliveries— outbound webhook config + audit loganalyticsEvents— server-side pageviews, partitioned monthly, GDPR-safe (no PII)auditLog— who did what when
Stored in contentTypes.fieldsSchema as JSON. Field types in v1:
text, textarea, richtext, number, boolean, date, datetime, email, url, color, select, multiselect, image, gallery, file, relation, repeater, json
Each field declares name, type, label, translatable, required, validation, and an optional admin block for editor hints.
Role-based with per-content-type granularity. A user has one role; a role has a JSON capability bundle:
{
"global": ["manageUsers", "manageRoles", "viewAnalytics"],
"types": {
"page": ["read", "create", "update", "delete", "publish", "readDrafts"],
"post": ["read", "create", "update", "delete", "publish", "readDrafts"],
"service": ["read", "create", "updateOwn", "deleteOwn"],
"*": ["read"]
}
}Per-type capabilities: read, create, update, updateOwn, delete, deleteOwn, publish, readDrafts, readOthersDrafts.
Global capabilities: manageUsers, manageRoles, manageTypes, manageSettings, manageMenus, manageRedirects, manageMedia, manageForms, viewAnalytics, viewAuditLog, exportData, manageJobs.
Built-in roles seeded at install:
admin— everythingeditor— full content CRUD + publish on all types; no user/role/settings managementauthor— CRU + publish on own posts; read on pagescontributor— CRU +updateOwnon assigned types; no publish (sends to review)viewer— read-only admin
The single can(user, action, type?, ownerId?) function gates every admin/API action. Per-request memoized.
Adding fields to a content type without downtime, without batch-updating all existing rows. The mechanism: versioned schemas + lazy migration on read.
- Each content type has a
currentRevisioninteger - Every schema change creates a row in
contentTypeRevisionswith the newfieldsSchemaand achangesJSON describing the diff (added,removed,renamed,retyped) - Every
contentrow stores theschemaRevisionit was saved against - On read, if
content.schemaRevision < type.currentRevision, walk revisions forward and apply changes to thefieldsJSON in memory - On next save, the migrated state is persisted;
schemaRevisionis bumped
Operation safety:
- Add optional field → silent; existing rows get default on read
- Add required field → modal asks for default value OR "mark existing as needs-review (block re-publish)" OR cancel
- Remove field → data preserved in
_legacynamespace; 30-day grace before hard purge - Rename field → silent if same type; auto-copy
- Change type → explicit transform required; rows that fail conversion flagged
Cache invalidation: schema-revision bump invalidates type-list:<slug>:* and all content:* of that type; admin can dry-run to see affected rows first.
The core perf strategy. The cache is not a plugin — it's the primary code path.
cache: Map<cacheKey, CacheEntry> // LRU-bounded
deps: Map<depKey, Set<cacheKey>> // reverse index for invalidation
cacheKey = canonical request signature, e.g. GET:/en/about:guest.
depKey examples: content:42, type-list:post:en, setting:site.name, menu:main.
- Request → compute cache key → cache hit? Yes: return cached buffer.
- Miss → resolve route → fetch content + relations (≤3 queries) → render JSX → record dep-keys → compress (brotli) → store → return.
- Subsequent identical requests hit cache (target <2ms TTFB).
On content publish/update/delete, on menu change, on setting change, on schema change:
- Compute affected
depKeys - Look up reverse-deps → set of
cacheKeys - Delete those cache entries + their reverse-index entries
- (Optional) Pre-render hot pages on background thread
- Fire webhook with same
depKeysso customer's frontend invalidates its cache the same way
Surrogate-Key headers carry the same depKeys so Fastly/Cloudflare can do surgical purges with the same vocabulary. No invalidation drift between layers.
We enforce SEO data quality at the API layer and provide ready-made markup helpers in @skelpo/site-kit. The split:
Required to publish — the publish endpoint returns validationError listing all failures on one response:
seo.metaDescriptionpresent, 70-160 charstitle≤ 60 chars (warn 60-70, block at 70+)- Every image in rich text content has
altTextset for the published locale - Hero/OG image is present (auto-uses first content image as fallback)
- Slug is URL-safe + ≤ 75 chars
- Canonical URL points to self unless explicitly overridden
- No two published rows share
(type, slug, locale)
Components the customer's frontend can drop in to get the SEO contract:
<Head content={x} site={settings} locale={l} />— emits title, meta description, canonical, hreflang alternates, OG, Twitter Card<JsonLd type="Article" content={x} />— emits schema.org JSON-LD per content type<Image src={mediaId} alt={...} sizes={...} priority />— emits<picture>with srcset, AVIF/WebP/JPEG sources, correct width/height/loading/fetchpriority<Form name="contact" />— renders form from CMS definition; POSTs to/api/v1/forms/contact/submitSitemap.respond({ cms })— generatessitemap.xmlroute handlerRobots.respond({ settings })— generatesrobots.txtLlms.respond({ cms })— generatesllms.txtfrom per-contentai.summaryfieldsFeed.respond({ cms, type: 'post' })— generates RSS/Atom
If the customer uses these defaults, they get the same SEO output the original "one binary" design would have produced. They can replace any of them without losing the data-layer guarantees.
| Content type | Default schema.org type |
|---|---|
| Page | WebPage |
| Post | Article / BlogPosting |
| Doc | TechArticle |
| Service (custom) | Service |
| Person (custom) | Person |
| Event (custom) | Event |
| Product (custom) | Product |
| Home page | WebSite + Organization always present |
Overridable per content row via seo.schemaType.
The customer's site is a separate Perry codebase (or Node/Bun) that:
- Has its own git repo (
skelpo-site-<customer>) - Owns all HTML, CSS, JS, layout, design
- Uses
@skelpo/cms-clientto call the CMS API - Uses
@skelpo/site-kit(optional) for SEO helpers - Receives webhooks from the CMS on content changes
- Is recompiled and redeployed by developers when templates change
- Is not rebuilt when editors change content/menus/settings — those update live
The customer's frontend has one catchall route:
// src/routes/[...path].tsx
import { cms } from './lib/cms'
import { PageTemplate, PostTemplate, DocTemplate, DefaultTemplate } from './templates'
export default async function CatchallRoute({ path, locale }) {
const resolved = await cms.content.byPath(path.join('/'), { locale })
if (resolved.redirect) return Response.redirect(resolved.redirect.to, resolved.redirect.status)
if (!resolved.content) return notFound()
switch (resolved.content.type) {
case 'page': return <PageTemplate content={resolved.content} />
case 'post': return <PostTemplate content={resolved.content} />
case 'doc': return <DocTemplate content={resolved.content} />
default: return <DefaultTemplate content={resolved.content} />
}
}Adding a page in admin = new content row → catchall resolves → template renders → live. No rebuild.
import { createClient, webhookHandler } from '@skelpo/cms-client'
const cms = createClient({
url: process.env.CMS_URL,
token: process.env.CMS_TOKEN,
cache: 'auto',
webhookSecret: process.env.WEBHOOK_SECRET
})
app.post('/webhook/cms', webhookHandler(cms))That's it. Cache invalidation is wired up; content changes propagate in ~100-500ms.
| Change | Live (no rebuild) | Requires rebuild |
|---|---|---|
| Page text, title, body | ✅ | |
| Menu items, order, nesting | ✅ | |
| New pages, new posts | ✅ | |
| Redirects | ✅ | |
| Settings (site name, social, contact) | ✅ | |
| Forms (fields, success message) | ✅ | |
| Media uploads, logo change | ✅ | |
| New custom content type fields | ✅ (visible in admin instantly; on site if template references) | |
| HTML structure / layout | ✅ | |
| CSS / colors / fonts | ✅ | |
| New page templates / routes | ✅ | |
| Brand-new content type with dedicated rendering | (admin: live) | (frontend: yes, add case branch) |
Two release cycles, fully decoupled.
# Docker
docker pull skelpo/cms:1.2.3 && docker compose up -d
# Bare binary
curl -L https://releases.skelpo.com/cms/1.2.3/skelpo-cms-linux-x64 -o skelpo-cms.new
mv skelpo-cms.new skelpo-cms && systemctl restart skelpo-cmsOn first boot of new version:
- Run pending migrations from
migrations/*.sql(tracked inmigrationstable; idempotent) - Reconcile built-in content types (schema evolution applied via revision system)
- Reconcile built-in roles + capabilities (new caps added, never overwrites custom)
- Reconcile built-in email templates (only seeds missing ones — user edits preserved)
- Reconcile default settings (only seeds missing keys)
- Bump static asset version stamp
- Boot HTTP server
Whole sequence is <500ms on warm DB. Behind a load balancer with two instances: zero downtime.
# In the customer's site repo
git pull && npm ci && perry build && deployThis is the customer's release cycle, on the customer's schedule. The CMS doesn't care.
- Patch (1.2.x): bug + perf + security. Always safe.
- Minor (1.x.0): new features, additive only at the DB/API level. Schema evolution keeps old content readable. Always backwards-compatible.
- Major (x.0.0): may change
@skelpo/cms-clientor@skelpo/site-kitAPI. Migration guide published. Shipped ~yearly.
The CMS REST API has its own version path (/api/v1); breaking API changes bump to /api/v2 with the old version supported alongside for a deprecation window.
The deployment is:
/srv/skelpo/
├── skelpo-cms ← the binary (upgrade target)
├── .env ← config (rarely changed)
└── uploads/ ← media (if local storage; alternatively S3)
- DB stays put during upgrades — never touched
- Media stays put — never touched
- No
wp-content/to merge - No theme files to back up
- No plugins to update
Backup: skelpo-cms backup > site.skelpo-backup produces a single file (DB dump + media tarball). Restore: skelpo-cms restore site.skelpo-backup. Done.
Designed for Perry. Supported on Node 22+ and Bun 1.2+ — same source code, three artifacts.
- Hono HTTP framework
@perryts/mysql(pure-TS wire-protocol driver, zero native deps; runs on Perry/Node/Bun)node:fs/promises,node:zlib(brotli/gzip)- Web APIs:
fetch,Request,Response,URL,Headers,crypto.subtle,crypto.getRandomValues,TextEncoder,TextDecoder,ReadableStream - bcryptjs (pure JS) for password hashing
- Shiki (pure JS) for syntax highlighting
- TipTap JSON → HTML renderer (pure TS)
- Pure-TS SMTP client; HTTP-based clients for Resend/Postmark/SES
- HTTP server boot — entry point detects Perry/Bun/Node and uses the appropriate Hono adapter
- Static asset embedding — Perry can embed via compile-time include; Node/Bun load from
dist/static/at boot - Background workers (future) —
perry/threadon Perry,node:worker_threadson Node/Bun. V1 uses in-process polling on all three.
The platform-specific surface is ~5 small files. Everywhere else is standard TypeScript.
- ❌
sharp(native C++) — use imgproxy sidecar - ❌ Native bindings (
@node-rs/*,node-gyp-required packages) - ❌
node:child_process— use HTTP services - ❌
node:cluster— use external process manager - ❌
require()(ESM only) - ❌
__dirname,__filename(useimport.meta.url) - ❌ Tree-sitter native bindings (use Shiki)
- ❌ Native
argon2(use bcryptjs)
strategy:
matrix:
runtime: [perry, bun-1.2, node-22, node-24]Same test suite runs against all four. PRs need green on all to merge.
Content & publishing
- Built-in types:
Page,Post,Media,User,Role,Menu,Setting,Form,FormSubmission,EmailTemplate,Redirect - Custom content types (ACF-style field schema)
- Drafts + scheduled publish
- Preview URLs (signed-token)
- Revision history with one-click rollback
- Bulk actions (status change, delete)
- TipTap rich text (no raw HTML pasting)
Forms & email
- Built-in forms pre-seeded: Contact, Newsletter signup, Quote request
- 11 fixed field types:
text, email, phone, textarea, checkbox, radio, select, multiselect, file, hidden, consent - Submissions persist regardless of email success
- Spam protection: honeypot + timing check + per-IP rate limit
- Async email via SMTP / Resend / Postmark / SES
- Editable email templates with variable interpolation + i18n
SEO & agent
- Mandatory
metaDescriptionand imagealtTextat publish - Auto sitemap, robots, llms.txt, RSS/Atom (via site-kit helpers)
- Auto schema.org JSON-LD per content type (via site-kit)
- OpenGraph + Twitter Card meta
- 301/302 redirect management
- Canonical + hreflang for i18n
Admin
- HTMX-based admin UI (server-rendered, no SPA build)
- First-run wizard (admin user, site name, locale, branding)
- 2FA (TOTP)
- Brute-force protection / rate limiting
- Password reset via email
- Activity log / audit trail
- Maintenance mode toggle
Media
- Upload + organize (alt text required, focal point)
- imgproxy-backed transforms with signed URLs
- oEmbed for YouTube/Vimeo/Twitter (cached at publish)
Operations
- CLI (
skelpo-cms user create,export,import,migrate,backup,restore) /healthz,/readyz,/metrics(Prometheus)- Structured JSON logs
- Single-file backup + restore
- Custom 404/500 pages (content type)
Performance & cache
- In-memory cache with dependency graph
- Brotli pre-compression
- Surrogate-Key headers for CDN integration
- Static export mode (
skelpo-cms export --out dist/)
i18n
- Row-per-locale model with
translationGroupId - Per-locale slugs (
/de/ueber-uns,/en/about-us) - Default-locale fallback
- Admin UI translated via Crowdin
Search
- Site-wide MySQL FTS indexed at publish
Analytics
- Server-side pageview tracking, no client JS
- Admin dashboard: top pages, referrers, timeseries
- GDPR-safe by design
Webhooks
- Outbound webhooks with HMAC signing
- Configurable events:
content.published,content.updated,menu.updated,setting.changed,form.submitted, etc. - Delivery audit log with retry
SDK
@skelpo/cms-client— typed REST client with auto-cache + webhook handler@skelpo/site-kit— opt-in SEO helpers (Head, JsonLd, Image, Form, Sitemap, Robots, Llms, Feed)skelpo-cms types-codegen— emits typed bindings from current schema
- Newsletter campaigns (compose + send to list)
- Per-post comments (opt-in per content type)
- Auto OG image generation (server-side composition)
- Search backend swap (Meilisearch / Tantivy)
- Multi-step forms
- Per-content access control (member-only pages)
- IndieAuth / WebMentions
- Calendar/events as first-class type
- Block-based page builder
- E-commerce (use Medusa)
- Memberships / paid subscriptions
- Forums, LMS, wiki, CRM
- Plugins with arbitrary code execution (use webhooks)
- Custom themes (frontend is the theme)
- Headless-only mode (it already is headless)
- Multi-tenant SaaS mode
Concrete list, in build order:
- Scaffold the
skelpo/cmsrepo:package.json,tsconfig.json,perry.config.json,.gitignore, CI workflow. - Schema migration runner + first migration (the full schema from
docs/schema.md). - Boot loop: Hono app, MySQL pool,
/healthz,/readyz, structured logs. - Auth: sessions + tokens, bcryptjs password hashing, login/logout/me, brute-force rate limit.
- Content read API:
GET /content,by-id,by-slug,by-pathwithincludeexpansion. - Content write API: POST/PATCH/DELETE/publish/schedule/revert.
- Content types API: CRUD + schema revisions + lazy migration.
- Cache layer: in-memory LRU + dep graph + ETag + Surrogate-Key emission.
- Media: upload, imgproxy URL signing, alt text enforcement.
- Menus, Settings, Redirects, Roles, Users.
- Forms + form submissions + email backends (start with Resend).
- Jobs queue (DB-backed polling worker).
- Webhooks outbound + HMAC signing + delivery log.
- Admin UI (HTMX) for: login, dashboard, content list/edit, types, menus, settings, users, forms, media, redirects, jobs, audit.
- First-run wizard.
@skelpo/cms-clientSDK + auto-cache + webhook handler + types-codegen.@skelpo/site-kitHead, JsonLd, Image, Form, Sitemap, Robots, Llms, Feed.skelpo-cms initCLI → generates starter site repo.- Static export mode (
skelpo-cms export). - Analytics ingest + dashboard.
- CLI: backup, restore, user-create, migrate, export, import.
- CI matrix: Perry + Node + Bun.
- Three distribution artifacts: Perry binary, Docker image, npm package.
- perry.land built on Skelpo CMS as proof-of-concept.
The planned source tree (subject to refinement as code lands):
skelpo-cms/
├── README.md ← this file
├── docs/
│ ├── api-spec.md ← REST API contract (v1)
│ ├── schema.md ← full SQL schema
│ ├── architecture.md ← deeper architecture notes
│ └── ops.md ← deployment / backup / restore
├── migrations/ ← *.sql files, applied in order
├── src/
│ ├── server.ts ← entry, runtime detection
│ ├── config.ts ← env → typed config
│ ├── app.ts ← Hono root app
│ ├── routes/
│ │ ├── api/
│ │ │ ├── content.ts | types.ts | media.ts | users.ts | roles.ts
│ │ │ ├── menus.ts | settings.ts | forms.ts | redirects.ts
│ │ │ ├── webhooks.ts | search.ts | analytics.ts | auth.ts
│ │ │ ├── jobs.ts | audit.ts | schema.ts
│ │ ├── admin/
│ │ │ ├── routes.ts
│ │ │ └── views/ ← JSX server-rendered fragments
│ │ ├── healthz.ts | metrics.ts | preview.ts
│ ├── db/
│ │ ├── client.ts ← @perryts/mysql pool
│ │ ├── content.ts | users.ts | roles.ts | media.ts | jobs.ts | …
│ │ └── migrate.ts
│ ├── cache/
│ │ ├── lru.ts | deps.ts | invalidate.ts | persist.ts
│ ├── render/
│ │ ├── richtext.tsx ← TipTap JSON → HTML
│ │ ├── highlight.ts ← Shiki at publish time
│ ├── auth/
│ │ ├── session.ts | totp.ts | password.ts | ratelimit.ts | tokens.ts
│ ├── permissions/check.ts
│ ├── forms/
│ ├── email/
│ │ ├── adapter.ts | smtp.ts | resend.ts | postmark.ts | ses.ts
│ ├── jobs/
│ │ ├── queue.ts | worker.ts | kinds/
│ ├── media/
│ │ ├── upload.ts | imgproxy.ts | storage/local.ts | storage/s3.ts
│ ├── search/
│ ├── analytics/
│ ├── webhooks/
│ ├── cli/
│ │ └── main.ts ← `skelpo-cms <subcommand>`
│ └── platform/ ← runtime-specific shims
│ ├── serve.ts | assets.ts | worker.ts
├── packages/
│ ├── cms-client/ ← @skelpo/cms-client (SDK)
│ └── site-kit/ ← @skelpo/site-kit (helpers)
├── starter/ ← `skelpo-cms init` copies this
├── tests/
├── docker/
│ └── Dockerfile
├── .github/workflows/
├── package.json
├── tsconfig.json
└── perry.config.json
node:test via tsx — zero extra test deps, runs on Node/Bun/Perry.
npm run test:unit # pure logic, no DB — runs anywhere (47 tests)
npm run test:integration # full API + admin UI vs a MySQL test DB (34 tests)
npm test # both — 81 total- Unit (
tests/unit/): permissions, cache (LRU + dep-graph + ETag), datetime normalization, password hashing, content-writer validation, all of@skelpo/site-kit, and the@skelpo/cms-clientcache/client. - Integration (
tests/integration/):api.test.ts— auth/ratelimit, content CRUD + publish + SEO-gate + cache + 304, schema evolution, menus/settings/redirects, users/roles, form spam, media alt-enforcement, webhook dispatch, full backup→wipe→restore FK-integrity round-trip.admin.test.ts— the HTMX admin: auth gate, login/logout, dashboard, content editor (create/publish/SEO-gate/ delete), and every secondary screen incl. their form posts. Each file resets the DB + boots a server; run serially. Auto-skips when no MySQL. (The perry-landing scripts are thin glue over the SDK + site-kit, both exhaustively covered by the suites above.) - CI:
.github/workflows/test.yml— Node 22 & 24, MySQL 8 service, typecheck (all 3 packages) + unit + integration.
The suite has already caught and fixed three real bugs: an updateOwn
authorization bypass, a TRUNCATE-on-FK-referenced-table restore failure,
and an empty-JSON-string restore crash.
docs/api-spec.md— REST API specification (v1)docs/schema.md— full SQL schema (to be written next)- Perry — the native TypeScript compiler this is designed for
- Hono — the HTTP framework
- @perryts/mysql — the MySQL driver (pure-TS wire protocol)
- TipTap — the rich text editor
- HTMX — the admin UI mechanism
- imgproxy — image transforms sidecar
- Shiki — syntax highlighting
Next step: approve this plan, then start scaffolding the skelpo-cms package + first migration.