{"id":50582983,"url":"https://github.com/skelpo/cms","last_synced_at":"2026-06-05T04:01:20.480Z","repository":{"id":359112997,"uuid":"1243880219","full_name":"skelpo/cms","owner":"skelpo","description":"A blazingly fast, opinionated, native TypeScript CMS. Designed for Perry AOT, runs on Node and Bun.","archived":false,"fork":false,"pushed_at":"2026-05-20T12:08:52.000Z","size":245,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-20T16:41:48.801Z","etag":null,"topics":["cms","headless-cms","hono","htmx","mysql","native","perry","typescript"],"latest_commit_sha":null,"homepage":"https://skelpo.com","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/skelpo.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-19T18:54:26.000Z","updated_at":"2026-05-20T12:08:54.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/skelpo/cms","commit_stats":null,"previous_names":["skelpo/cms"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/skelpo/cms","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skelpo%2Fcms","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skelpo%2Fcms/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skelpo%2Fcms/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skelpo%2Fcms/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/skelpo","download_url":"https://codeload.github.com/skelpo/cms/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skelpo%2Fcms/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33928631,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-05T02:00:06.157Z","response_time":120,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cms","headless-cms","hono","htmx","mysql","native","perry","typescript"],"created_at":"2026-06-05T04:01:19.635Z","updated_at":"2026-06-05T04:01:20.471Z","avatar_url":"https://github.com/skelpo.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Skelpo CMS\n\n\u003e A blazingly fast, opinionated, native TypeScript CMS for agencies and small businesses.\n\u003e Designed for [Perry](https://github.com/PerryTS/perry) AOT compilation. Runs on Node and Bun too.\n\n**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`).\n\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)\n[![@skelpo/cms-client](https://img.shields.io/npm/v/@skelpo/cms-client?label=%40skelpo%2Fcms-client)](https://www.npmjs.com/package/@skelpo/cms-client)\n[![@skelpo/site-kit](https://img.shields.io/npm/v/@skelpo/site-kit?label=%40skelpo%2Fsite-kit)](https://www.npmjs.com/package/@skelpo/site-kit)\n\n**License:** MIT. **Maintained by** [Skelpo GmbH](https://skelpo.com).\n\n---\n\n## Table of contents\n\n- [What is Skelpo CMS](#what-is-skelpo-cms)\n- [Philosophy](#philosophy)\n- [Architecture](#architecture)\n- [Performance budget](#performance-budget)\n- [Data model](#data-model)\n- [Permissions](#permissions)\n- [Schema evolution](#schema-evolution)\n- [Cache \u0026 invalidation](#cache--invalidation)\n- [SEO \u0026 agent optimization](#seo--agent-optimization)\n- [Customer frontend (the public site)](#customer-frontend-the-public-site)\n- [Upgradability](#upgradability)\n- [Multi-runtime support](#multi-runtime-support)\n- [Feature scope](#feature-scope)\n- [v0.1 deliverables](#v01-deliverables)\n- [Repository layout](#repository-layout)\n- [References](#references)\n\n---\n\n## What is Skelpo CMS\n\nSkelpo 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](https://medusajs.com) for commerce).\n\nIt is:\n\n- **Headless** — backend API + admin UI only; the public website is a separate codebase owned by the customer.\n- **API-first** — every admin action goes through the same REST API a mobile app or external integration would use.\n- **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.\n- **Blazingly fast** — designed for Perry AOT compilation. Sub-2ms cached responses. 100K+ RPS on commodity hardware. \u003c50ms cold start.\n- **Multi-runtime** — runs on Perry (recommended), Node, or Bun. Same code, three artifacts.\n- **MySQL-backed** — one database, no choice paralysis.\n- **Single-tenant** — one deploy per site. No shared-state surprises. Multi-tenancy is out of scope.\n- **Upgrade-safe** — the CMS binary and the customer's frontend binary upgrade independently. No file migrations. No theme merges. No `wp-content/` dance.\n\nIt is **not**:\n\n- A page builder with drag-drop block editing (TipTap rich text is the editor)\n- A theme marketplace (no themes — the customer's frontend is the theme)\n- A plugin platform with arbitrary code execution (custom content types + webhooks are the extension surface)\n- An e-commerce platform (use Medusa)\n- A membership/subscription system\n- A multi-tenant SaaS\n\n---\n\n## Philosophy\n\nThe opinionated stances, stated plainly. These are non-negotiable in v1; they're load-bearing for the design.\n\n1. **MySQL only.** No \"supports MySQL/Postgres/SQLite.\" MySQL via [`@perryts/mysql`](https://www.npmjs.com/package/@perryts/mysql) — a pure-TypeScript wire-protocol driver, zero native deps. Runs on Perry, Node, and Bun. AOT-compiles cleanly.\n2. **No themes inside the CMS.** The customer's frontend is the theme. The CMS doesn't render public pages.\n3. **No plugins.** Custom content types + webhooks are the only extension surface. Arbitrary code execution is a security and upgrade nightmare we explicitly avoid.\n4. **TipTap rich text.** No Gutenberg blocks. No alternate editors. No raw HTML pasting (TipTap JSON only — keeps cache + render invariants safe).\n5. **Meta description and image alt text are required.** Publish is blocked without them. SEO + accessibility + LLM-friendliness aren't optional.\n6. **Forms have a fixed set of 11 field types.** That's all. Don't ask.\n7. **Email sending is always async.** No synchronous send. Failures retry. Submissions always persist regardless of mail success.\n8. **One email backend at a time.** Configure SMTP *or* Resend *or* Postmark *or* SES. No \"fallback chains.\"\n9. **Server-side analytics, no client JS.** GDPR-compliant by default (anonymized IP hashes, no cookies, no PII).\n10. **Single-tenant, single-binary CMS.** One deploy per site. No multi-tenant mode.\n11. **Performance is a CI gate.** The perf budget below is enforced. PRs that break it fail.\n12. **The CMS admin is uniform.** Customers don't theme it. Editors get a consistent experience across all Skelpo sites.\n13. **Editors never rebuild.** Content, menus, settings, forms, redirects — all changes go live via webhook+cache, no recompilation.\n14. **Developers always control structure.** HTML, CSS, JS — fully owned by the customer's frontend codebase. Recompile + deploy on design changes.\n15. **Native deps in core: zero.** No Sharp, no native argon2, no tree-sitter, no `node-gyp`. Image processing via imgproxy sidecar.\n\nIf you disagree with any of these, Skelpo CMS is probably not your tool. That's fine — pick one of the many other excellent CMSes.\n\n---\n\n## Architecture\n\nSkelpo CMS is a **two-binary system**: a backend and a frontend. They communicate over HTTP.\n\n```\n┌──────────────────────────────────────┐    ┌──────────────────────────────────┐\n│ skelpo-cms (backend)                  │    │ skelpo-site-\u003ccustomer\u003e (frontend)│\n│                                       │    │                                   │\n│  - REST API (/api/v1/*)               │    │ - Customer-owned codebase        │\n│  - Admin UI (HTMX, /admin/*)          │◄───┤ - Full HTML/CSS/JS control       │\n│  - Auth (sessions + bearer tokens)    │    │ - Their own JSX templates        │\n│  - Media uploads + imgproxy signing   │    │ - Their own design system        │\n│  - Forms + email + jobs               │    │ - Perry-compiled (or Node/Bun)   │\n│  - Webhooks (outbound)                │───►│ - Receives webhooks for live     │\n│  - Single Perry binary, ~15 MB        │    │   cache invalidation             │\n└──────────────────────────────────────┘    └──────────────────────────────────┘\n                  │                                          │\n              MySQL                              uses @skelpo/cms-client\n              media (S3/local + imgproxy)              and @skelpo/site-kit\n```\n\n### What the CMS binary does\n\n- Serves the REST API on `/api/v1/*` — content, types, media, users, menus, forms, settings, webhooks, search, analytics\n- Serves the HTMX admin UI on `/admin/*` — uniform across all Skelpo sites\n- Handles authentication (sessions for browser, bearer tokens for SDK/mobile)\n- Runs background jobs (publish scheduled content, send emails, fire webhooks, regen sitemaps)\n- Validates all writes; enforces required SEO/accessibility fields at publish time\n- Never renders public-facing HTML\n\n### What the customer's frontend does\n\n- Renders **every public page** with full HTML/CSS/JS control\n- Calls the CMS API via `@skelpo/cms-client` to fetch content, menus, settings\n- Handles its own caching (the SDK provides this)\n- Receives webhook notifications from the CMS on content changes → invalidates cache → next visitor sees fresh data\n- Implements public routes (catchall pattern resolves any content URL → API → template)\n- Owned and deployed by the customer; recompiled when design changes\n\n### Three published artifacts per release\n\n| Artifact | Format | Target use |\n|---|---|---|\n| **Perry binary** | Single executable (~15 MB) | Recommended production |\n| **Docker image** | `skelpo/cms:1.x.y` | Container deploys |\n| **npm package** | `@skelpo/cms` (CJS+ESM) | Users on Node/Bun who want their existing runtime |\n\nSame source code produces all three.\n\n---\n\n## Performance budget\n\nThese are CI-gated. PRs that break them fail. Numbers are on Perry; Node/Bun are slower but still beat WordPress and Strapi.\n\n| Metric | Target (Perry) | Target (Bun) | Target (Node) |\n|---|---|---|---|\n| Binary / package size | \u003c20 MB | \u003c50 MB | \u003c100 MB |\n| Cold start | \u003c50 ms | \u003c200 ms | \u003c800 ms |\n| Cached page TTFB (p99 local) | \u003c2 ms | \u003c5 ms | \u003c10 ms |\n| Uncached page TTFB (p99 local) | \u003c10 ms | \u003c30 ms | \u003c60 ms |\n| Memory idle RSS | \u003c50 MB | \u003c100 MB | \u003c200 MB |\n| Cached RPS (single instance) | 100K+ | 30-50K | 10-20K |\n| DB queries per cached request | **0** | 0 | 0 |\n| DB queries per uncached page | ≤3 | ≤3 | ≤3 |\n| Cache hit ratio (public traffic) | \u003e95% | \u003e95% | \u003e95% |\n\n### How we hit these numbers\n\n- **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.\n- **Compiled JSX templates.** No template engine, no AST walking per request. (Customer's site benefits the same way via Perry compilation.)\n- **Brotli pre-compressed cache.** Cached buffer is already wire bytes; `write(2)` directly to socket.\n- **Zero allocations on cache hit.** Return a pointer to the cached buffer.\n- **Native syscalls.** `sendfile(2)` for static assets and cached HTML; `splice`-style zero-copy where possible (on Perry).\n- **In-process cache with surgical dependency graph.** No Redis required for single-tenant deploys.\n- **Single-binary horizontal scaling.** \u003c50ms cold start means autoscale-from-zero is real.\n- **One indexed query per content fetch.** Custom fields in a JSON column; relations fetched in one second query if `?include=` is used.\n\n### Comparison vs. existing CMSes (target)\n\n| | WordPress | Strapi 5 | Directus | Payload v3 | **Skelpo (Perry)** |\n|---|---|---|---|---|---|\n| Cold start | ~500 ms | ~2000 ms | ~3000 ms | ~3000 ms | **\u003c50 ms** |\n| RPS (cached) | ~500 | ~5K | ~5K | ~5K | **\u003e100K** |\n| RPS (uncached) | ~50 | ~1K | ~1K | ~1K | **\u003e10K** |\n| Memory idle | 100MB×N workers | ~300 MB | ~400 MB | ~500 MB | **\u003c50 MB** |\n| Native deps | PHP + ext | Node + 1000 npm | Node + 800 npm + Sharp | Node + Next + 1500 npm | **just imgproxy** |\n| Static export built-in | No | No | No | No | **Yes** |\n\n### Measured — Node vs Perry (direct head-to-head)\n\nIdentical Fastify source, same machine (M-series Mac), same harness\n(`autocannon`, 50 conns × 20 s). Full writeup +\n[scripts/bench-twin/](scripts/bench-twin/) reproducer in\n[docs/benchmarks-perry-vs-node.md](docs/benchmarks-perry-vs-node.md);\ndeployed-CMS end-to-end numbers in [docs/benchmarks.md](docs/benchmarks.md).\n\n| Axis | Node + tsx | Perry native | Δ |\n|---|---:|---:|---:|\n| Cold start (spawn → 200) | 730 ms | **44 ms** | ≈17× faster |\n| RPS, /loop (CPU bound) | 49,947 | **67,197** | +35% |\n| RPS, /json (1KB serialize) | 53,498 | **65,766** | +23% |\n| RPS, /healthz (tiny JSON) | 58,522 | **65,723** | +12% |\n| RSS, idle | 86 MB | **11 MB** | ≈8× smaller |\n| Distributable | ~105 MB (node + node_modules) | **3.5 MB** binary | ≈30× smaller |\n\nResponses are byte-identical (md5-verified). Throughput is +20% on\naverage at this concurrency; the lopsided wins are cold start (≈17×),\nidle memory (≈8×), and deployable size (≈30×) — the axes that matter\nfor autoscale-from-zero, FaaS, and edge/CLI shapes.\n\n---\n\n## Data model\n\nThe full SQL schema lives at [`docs/schema.md`](docs/schema.md) (next deliverable). High-level overview:\n\n### Core tables\n\n- **`contentTypes`** — type definitions including the JSON `fieldsSchema`\n- **`contentTypeRevisions`** — schema history, enables lazy migration\n- **`content`** — every piece of content (built-in types + custom), with JSON `fields`, `seo`, `ai` columns\n- **`contentRelations`** — many-to-many relation links\n- **`contentRevisions`** — content edit history per row\n\n### Auth \u0026 permissions\n\n- **`users`** — with bcryptjs password hashes + optional TOTP\n- **`roles`** — capability bundles (JSON)\n- **`sessions`** — DB-backed sessions for admin browser\n\n### Operations\n\n- **`media`** — uploaded files (alt text per locale, focal points)\n- **`menus`** + **`menuItems`** — drag-drop-buildable in admin\n- **`settings`** — flat key-value store (`site.name`, `seo.organizationSchema`, etc.)\n- **`redirects`** — 301/302 management (critical for SEO when URLs change)\n- **`emailTemplates`** — editable templates with variable interpolation\n- **`formSubmissions`** — every form submission persists, regardless of email success\n- **`jobs`** — DB-backed background queue (sendEmail, preRender, webhookDispatch, regenSitemap, etc.)\n- **`webhooks`** + **`webhookDeliveries`** — outbound webhook config + audit log\n- **`analyticsEvents`** — server-side pageviews, partitioned monthly, GDPR-safe (no PII)\n- **`auditLog`** — who did what when\n\n### Field types in the ACF-style schema\n\nStored in `contentTypes.fieldsSchema` as JSON. Field types in v1:\n\n`text, textarea, richtext, number, boolean, date, datetime, email, url, color, select, multiselect, image, gallery, file, relation, repeater, json`\n\nEach field declares `name`, `type`, `label`, `translatable`, `required`, `validation`, and an optional `admin` block for editor hints.\n\n---\n\n## Permissions\n\nRole-based with per-content-type granularity. A user has one role; a role has a JSON capability bundle:\n\n```json\n{\n  \"global\": [\"manageUsers\", \"manageRoles\", \"viewAnalytics\"],\n  \"types\": {\n    \"page\":    [\"read\", \"create\", \"update\", \"delete\", \"publish\", \"readDrafts\"],\n    \"post\":    [\"read\", \"create\", \"update\", \"delete\", \"publish\", \"readDrafts\"],\n    \"service\": [\"read\", \"create\", \"updateOwn\", \"deleteOwn\"],\n    \"*\":       [\"read\"]\n  }\n}\n```\n\n**Per-type capabilities:** `read, create, update, updateOwn, delete, deleteOwn, publish, readDrafts, readOthersDrafts`.\n\n**Global capabilities:** `manageUsers, manageRoles, manageTypes, manageSettings, manageMenus, manageRedirects, manageMedia, manageForms, viewAnalytics, viewAuditLog, exportData, manageJobs`.\n\n**Built-in roles seeded at install:**\n\n- `admin` — everything\n- `editor` — full content CRUD + publish on all types; no user/role/settings management\n- `author` — CRU + publish on own posts; read on pages\n- `contributor` — CRU + `updateOwn` on assigned types; no publish (sends to review)\n- `viewer` — read-only admin\n\nThe single `can(user, action, type?, ownerId?)` function gates every admin/API action. Per-request memoized.\n\n---\n\n## Schema evolution\n\nAdding fields to a content type without downtime, without batch-updating all existing rows. The mechanism: **versioned schemas + lazy migration on read**.\n\n- Each content type has a `currentRevision` integer\n- Every schema change creates a row in `contentTypeRevisions` with the new `fieldsSchema` and a `changes` JSON describing the diff (`added`, `removed`, `renamed`, `retyped`)\n- Every `content` row stores the `schemaRevision` it was saved against\n- On read, if `content.schemaRevision \u003c type.currentRevision`, walk revisions forward and apply changes to the `fields` JSON in memory\n- On next save, the migrated state is persisted; `schemaRevision` is bumped\n\n**Operation safety:**\n\n- Add optional field → silent; existing rows get default on read\n- Add required field → modal asks for default value OR \"mark existing as needs-review (block re-publish)\" OR cancel\n- Remove field → data preserved in `_legacy` namespace; 30-day grace before hard purge\n- Rename field → silent if same type; auto-copy\n- Change type → explicit transform required; rows that fail conversion flagged\n\n**Cache invalidation:** schema-revision bump invalidates `type-list:\u003cslug\u003e:*` and all `content:*` of that type; admin can dry-run to see affected rows first.\n\n---\n\n## Cache \u0026 invalidation\n\nThe core perf strategy. The cache is not a plugin — it's the primary code path.\n\n### Two in-memory structures\n\n```\ncache:  Map\u003ccacheKey, CacheEntry\u003e          // LRU-bounded\ndeps:   Map\u003cdepKey, Set\u003ccacheKey\u003e\u003e          // reverse index for invalidation\n```\n\n`cacheKey` = canonical request signature, e.g. `GET:/en/about:guest`.\n`depKey` examples: `content:42`, `type-list:post:en`, `setting:site.name`, `menu:main`.\n\n### Render path\n\n1. Request → compute cache key → cache hit? Yes: return cached buffer.\n2. Miss → resolve route → fetch content + relations (≤3 queries) → render JSX → record dep-keys → compress (brotli) → store → return.\n3. Subsequent identical requests hit cache (target \u003c2ms TTFB).\n\n### Invalidation\n\nOn content publish/update/delete, on menu change, on setting change, on schema change:\n\n1. Compute affected `depKey`s\n2. Look up reverse-deps → set of `cacheKey`s\n3. Delete those cache entries + their reverse-index entries\n4. (Optional) Pre-render hot pages on background thread\n5. Fire webhook with same `depKeys` so customer's frontend invalidates *its* cache the same way\n\n### CDN integration\n\nSurrogate-Key headers carry the same `depKeys` so Fastly/Cloudflare can do surgical purges with the same vocabulary. No invalidation drift between layers.\n\n---\n\n## SEO \u0026 agent optimization\n\nWe enforce SEO data quality at the API layer and provide ready-made markup helpers in `@skelpo/site-kit`. The split:\n\n### Enforced in `skelpo-cms` (data quality)\n\nRequired to publish — the publish endpoint returns `validationError` listing all failures on one response:\n\n1. `seo.metaDescription` present, 70-160 chars\n2. `title` ≤ 60 chars (warn 60-70, block at 70+)\n3. Every image in rich text content has `altText` set for the published locale\n4. Hero/OG image is present (auto-uses first content image as fallback)\n5. Slug is URL-safe + ≤ 75 chars\n6. Canonical URL points to self unless explicitly overridden\n7. No two published rows share `(type, slug, locale)`\n\n### Provided in `@skelpo/site-kit` (markup helpers, opt-in)\n\nComponents the customer's frontend can drop in to get the SEO contract:\n\n- `\u003cHead content={x} site={settings} locale={l} /\u003e` — emits title, meta description, canonical, hreflang alternates, OG, Twitter Card\n- `\u003cJsonLd type=\"Article\" content={x} /\u003e` — emits schema.org JSON-LD per content type\n- `\u003cImage src={mediaId} alt={...} sizes={...} priority /\u003e` — emits `\u003cpicture\u003e` with srcset, AVIF/WebP/JPEG sources, correct width/height/loading/fetchpriority\n- `\u003cForm name=\"contact\" /\u003e` — renders form from CMS definition; POSTs to `/api/v1/forms/contact/submit`\n- `Sitemap.respond({ cms })` — generates `sitemap.xml` route handler\n- `Robots.respond({ settings })` — generates `robots.txt`\n- `Llms.respond({ cms })` — generates `llms.txt` from per-content `ai.summary` fields\n- `Feed.respond({ cms, type: 'post' })` — generates RSS/Atom\n\nIf 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.\n\n### Schema.org types per content type\n\n| Content type | Default schema.org type |\n|---|---|\n| Page | `WebPage` |\n| Post | `Article` / `BlogPosting` |\n| Doc | `TechArticle` |\n| Service (custom) | `Service` |\n| Person (custom) | `Person` |\n| Event (custom) | `Event` |\n| Product (custom) | `Product` |\n| Home page | `WebSite` + `Organization` always present |\n\nOverridable per content row via `seo.schemaType`.\n\n---\n\n## Customer frontend (the public site)\n\nThe customer's site is a **separate Perry codebase** (or Node/Bun) that:\n\n- Has its own git repo (`skelpo-site-\u003ccustomer\u003e`)\n- Owns all HTML, CSS, JS, layout, design\n- Uses `@skelpo/cms-client` to call the CMS API\n- Uses `@skelpo/site-kit` (optional) for SEO helpers\n- Receives webhooks from the CMS on content changes\n- Is recompiled and redeployed by developers when templates change\n- Is **not** rebuilt when editors change content/menus/settings — those update live\n\n### Catchall routing pattern\n\nThe customer's frontend has one catchall route:\n\n```tsx\n// src/routes/[...path].tsx\nimport { cms } from './lib/cms'\nimport { PageTemplate, PostTemplate, DocTemplate, DefaultTemplate } from './templates'\n\nexport default async function CatchallRoute({ path, locale }) {\n  const resolved = await cms.content.byPath(path.join('/'), { locale })\n  if (resolved.redirect) return Response.redirect(resolved.redirect.to, resolved.redirect.status)\n  if (!resolved.content) return notFound()\n\n  switch (resolved.content.type) {\n    case 'page':    return \u003cPageTemplate    content={resolved.content} /\u003e\n    case 'post':    return \u003cPostTemplate    content={resolved.content} /\u003e\n    case 'doc':     return \u003cDocTemplate     content={resolved.content} /\u003e\n    default:        return \u003cDefaultTemplate content={resolved.content} /\u003e\n  }\n}\n```\n\nAdding a page in admin = new content row → catchall resolves → template renders → live. No rebuild.\n\n### Webhook handler (one line of wiring)\n\n```ts\nimport { createClient, webhookHandler } from '@skelpo/cms-client'\n\nconst cms = createClient({\n  url: process.env.CMS_URL,\n  token: process.env.CMS_TOKEN,\n  cache: 'auto',\n  webhookSecret: process.env.WEBHOOK_SECRET\n})\n\napp.post('/webhook/cms', webhookHandler(cms))\n```\n\nThat's it. Cache invalidation is wired up; content changes propagate in ~100-500ms.\n\n### Live vs. rebuild — the divide\n\n| Change | Live (no rebuild) | Requires rebuild |\n|---|---|---|\n| Page text, title, body | ✅ | |\n| Menu items, order, nesting | ✅ | |\n| New pages, new posts | ✅ | |\n| Redirects | ✅ | |\n| Settings (site name, social, contact) | ✅ | |\n| Forms (fields, success message) | ✅ | |\n| Media uploads, logo change | ✅ | |\n| New custom content type fields | ✅ (visible in admin instantly; on site if template references) | |\n| HTML structure / layout | | ✅ |\n| CSS / colors / fonts | | ✅ |\n| New page templates / routes | | ✅ |\n| Brand-new content type with dedicated rendering | (admin: live) | (frontend: yes, add case branch) |\n\n---\n\n## Upgradability\n\nTwo release cycles, fully decoupled.\n\n### Upgrading the CMS\n\n```bash\n# Docker\ndocker pull skelpo/cms:1.2.3 \u0026\u0026 docker compose up -d\n\n# Bare binary\ncurl -L https://releases.skelpo.com/cms/1.2.3/skelpo-cms-linux-x64 -o skelpo-cms.new\nmv skelpo-cms.new skelpo-cms \u0026\u0026 systemctl restart skelpo-cms\n```\n\nOn first boot of new version:\n\n1. Run pending migrations from `migrations/*.sql` (tracked in `migrations` table; idempotent)\n2. Reconcile built-in content types (schema evolution applied via revision system)\n3. Reconcile built-in roles + capabilities (new caps added, never overwrites custom)\n4. Reconcile built-in email templates (only seeds missing ones — user edits preserved)\n5. Reconcile default settings (only seeds missing keys)\n6. Bump static asset version stamp\n7. Boot HTTP server\n\nWhole sequence is \u003c500ms on warm DB. Behind a load balancer with two instances: zero downtime.\n\n### Upgrading the customer's frontend\n\n```bash\n# In the customer's site repo\ngit pull \u0026\u0026 npm ci \u0026\u0026 perry build \u0026\u0026 deploy\n```\n\nThis is the customer's release cycle, on the customer's schedule. The CMS doesn't care.\n\n### Semver contract\n\n- **Patch (1.2.x):** bug + perf + security. Always safe.\n- **Minor (1.x.0):** new features, additive only at the DB/API level. Schema evolution keeps old content readable. Always backwards-compatible.\n- **Major (x.0.0):** may change `@skelpo/cms-client` or `@skelpo/site-kit` API. Migration guide published. Shipped ~yearly.\n\nThe 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.\n\n### No files to manage\n\nThe deployment is:\n\n```\n/srv/skelpo/\n├── skelpo-cms        ← the binary (upgrade target)\n├── .env              ← config (rarely changed)\n└── uploads/          ← media (if local storage; alternatively S3)\n```\n\n- **DB stays put** during upgrades — never touched\n- **Media stays put** — never touched\n- **No `wp-content/` to merge**\n- **No theme files to back up**\n- **No plugins to update**\n\nBackup: `skelpo-cms backup \u003e site.skelpo-backup` produces a single file (DB dump + media tarball). Restore: `skelpo-cms restore site.skelpo-backup`. Done.\n\n---\n\n## Multi-runtime support\n\nDesigned for Perry. Supported on Node 22+ and Bun 1.2+ — same source code, three artifacts.\n\n### What works the same on all three\n\n- Hono HTTP framework\n- `@perryts/mysql` (pure-TS wire-protocol driver, zero native deps; runs on Perry/Node/Bun)\n- `node:fs/promises`, `node:zlib` (brotli/gzip)\n- Web APIs: `fetch`, `Request`, `Response`, `URL`, `Headers`, `crypto.subtle`, `crypto.getRandomValues`, `TextEncoder`, `TextDecoder`, `ReadableStream`\n- bcryptjs (pure JS) for password hashing\n- Shiki (pure JS) for syntax highlighting\n- TipTap JSON → HTML renderer (pure TS)\n- Pure-TS SMTP client; HTTP-based clients for Resend/Postmark/SES\n\n### What needs runtime detection (the small platform layer)\n\n- **HTTP server boot** — entry point detects Perry/Bun/Node and uses the appropriate Hono adapter\n- **Static asset embedding** — Perry can embed via compile-time include; Node/Bun load from `dist/static/` at boot\n- **Background workers (future)** — `perry/thread` on Perry, `node:worker_threads` on Node/Bun. V1 uses in-process polling on all three.\n\nThe platform-specific surface is ~5 small files. Everywhere else is standard TypeScript.\n\n### What we explicitly avoid\n\n- ❌ `sharp` (native C++) — use imgproxy sidecar\n- ❌ Native bindings (`@node-rs/*`, `node-gyp`-required packages)\n- ❌ `node:child_process` — use HTTP services\n- ❌ `node:cluster` — use external process manager\n- ❌ `require()` (ESM only)\n- ❌ `__dirname`, `__filename` (use `import.meta.url`)\n- ❌ Tree-sitter native bindings (use Shiki)\n- ❌ Native `argon2` (use bcryptjs)\n\n### CI matrix\n\n```yaml\nstrategy:\n  matrix:\n    runtime: [perry, bun-1.2, node-22, node-24]\n```\n\nSame test suite runs against all four. PRs need green on all to merge.\n\n---\n\n## Feature scope\n\n### Tier 1 — ships in v0.1 (the curated set)\n\n**Content \u0026 publishing**\n\n- Built-in types: `Page`, `Post`, `Media`, `User`, `Role`, `Menu`, `Setting`, `Form`, `FormSubmission`, `EmailTemplate`, `Redirect`\n- Custom content types (ACF-style field schema)\n- Drafts + scheduled publish\n- Preview URLs (signed-token)\n- Revision history with one-click rollback\n- Bulk actions (status change, delete)\n- TipTap rich text (no raw HTML pasting)\n\n**Forms \u0026 email**\n\n- Built-in forms pre-seeded: Contact, Newsletter signup, Quote request\n- 11 fixed field types: `text, email, phone, textarea, checkbox, radio, select, multiselect, file, hidden, consent`\n- Submissions persist regardless of email success\n- Spam protection: honeypot + timing check + per-IP rate limit\n- Async email via SMTP / Resend / Postmark / SES\n- Editable email templates with variable interpolation + i18n\n\n**SEO \u0026 agent**\n\n- Mandatory `metaDescription` and image `altText` at publish\n- Auto sitemap, robots, llms.txt, RSS/Atom (via site-kit helpers)\n- Auto schema.org JSON-LD per content type (via site-kit)\n- OpenGraph + Twitter Card meta\n- 301/302 redirect management\n- Canonical + hreflang for i18n\n\n**Admin**\n\n- HTMX-based admin UI (server-rendered, no SPA build)\n- First-run wizard (admin user, site name, locale, branding)\n- 2FA (TOTP)\n- Brute-force protection / rate limiting\n- Password reset via email\n- Activity log / audit trail\n- Maintenance mode toggle\n\n**Media**\n\n- Upload + organize (alt text required, focal point)\n- imgproxy-backed transforms with signed URLs\n- oEmbed for YouTube/Vimeo/Twitter (cached at publish)\n\n**Operations**\n\n- CLI (`skelpo-cms user create`, `export`, `import`, `migrate`, `backup`, `restore`)\n- `/healthz`, `/readyz`, `/metrics` (Prometheus)\n- Structured JSON logs\n- Single-file backup + restore\n- Custom 404/500 pages (content type)\n\n**Performance \u0026 cache**\n\n- In-memory cache with dependency graph\n- Brotli pre-compression\n- Surrogate-Key headers for CDN integration\n- Static export mode (`skelpo-cms export --out dist/`)\n\n**i18n**\n\n- Row-per-locale model with `translationGroupId`\n- Per-locale slugs (`/de/ueber-uns`, `/en/about-us`)\n- Default-locale fallback\n- Admin UI translated via Crowdin\n\n**Search**\n\n- Site-wide MySQL FTS indexed at publish\n\n**Analytics**\n\n- Server-side pageview tracking, no client JS\n- Admin dashboard: top pages, referrers, timeseries\n- GDPR-safe by design\n\n**Webhooks**\n\n- Outbound webhooks with HMAC signing\n- Configurable events: `content.published`, `content.updated`, `menu.updated`, `setting.changed`, `form.submitted`, etc.\n- Delivery audit log with retry\n\n**SDK**\n\n- `@skelpo/cms-client` — typed REST client with auto-cache + webhook handler\n- `@skelpo/site-kit` — opt-in SEO helpers (Head, JsonLd, Image, Form, Sitemap, Robots, Llms, Feed)\n- `skelpo-cms types-codegen` — emits typed bindings from current schema\n\n### Tier 2 — v0.2+\n\n- Newsletter campaigns (compose + send to list)\n- Per-post comments (opt-in per content type)\n- Auto OG image generation (server-side composition)\n- Search backend swap (Meilisearch / Tantivy)\n- Multi-step forms\n- Per-content access control (member-only pages)\n- IndieAuth / WebMentions\n- Calendar/events as first-class type\n- Block-based page builder\n\n### Tier 3 — hard no, out of scope\n\n- E-commerce (use Medusa)\n- Memberships / paid subscriptions\n- Forums, LMS, wiki, CRM\n- Plugins with arbitrary code execution (use webhooks)\n- Custom themes (frontend is the theme)\n- Headless-only mode (it already is headless)\n- Multi-tenant SaaS mode\n\n---\n\n## v0.1 deliverables\n\nConcrete list, in build order:\n\n1. **Scaffold** the `skelpo/cms` repo: `package.json`, `tsconfig.json`, `perry.config.json`, `.gitignore`, CI workflow.\n2. **Schema migration runner** + first migration (the full schema from `docs/schema.md`).\n3. **Boot loop**: Hono app, MySQL pool, `/healthz`, `/readyz`, structured logs.\n4. **Auth**: sessions + tokens, bcryptjs password hashing, login/logout/me, brute-force rate limit.\n5. **Content read API**: `GET /content`, `by-id`, `by-slug`, `by-path` with `include` expansion.\n6. **Content write API**: POST/PATCH/DELETE/publish/schedule/revert.\n7. **Content types API**: CRUD + schema revisions + lazy migration.\n8. **Cache layer**: in-memory LRU + dep graph + ETag + Surrogate-Key emission.\n9. **Media**: upload, imgproxy URL signing, alt text enforcement.\n10. **Menus, Settings, Redirects, Roles, Users**.\n11. **Forms** + form submissions + email backends (start with Resend).\n12. **Jobs queue** (DB-backed polling worker).\n13. **Webhooks** outbound + HMAC signing + delivery log.\n14. **Admin UI** (HTMX) for: login, dashboard, content list/edit, types, menus, settings, users, forms, media, redirects, jobs, audit.\n15. **First-run wizard**.\n16. **`@skelpo/cms-client`** SDK + auto-cache + webhook handler + types-codegen.\n17. **`@skelpo/site-kit`** Head, JsonLd, Image, Form, Sitemap, Robots, Llms, Feed.\n18. **`skelpo-cms init`** CLI → generates starter site repo.\n19. **Static export mode** (`skelpo-cms export`).\n20. **Analytics ingest + dashboard**.\n21. **CLI**: backup, restore, user-create, migrate, export, import.\n22. **CI matrix**: Perry + Node + Bun.\n23. **Three distribution artifacts**: Perry binary, Docker image, npm package.\n24. **perry.land** built on Skelpo CMS as proof-of-concept.\n\n---\n\n## Repository layout\n\nThe planned source tree (subject to refinement as code lands):\n\n```\nskelpo-cms/\n├── README.md                  ← this file\n├── docs/\n│   ├── api-spec.md            ← REST API contract (v1)\n│   ├── schema.md              ← full SQL schema\n│   ├── architecture.md        ← deeper architecture notes\n│   └── ops.md                 ← deployment / backup / restore\n├── migrations/                ← *.sql files, applied in order\n├── src/\n│   ├── server.ts              ← entry, runtime detection\n│   ├── config.ts              ← env → typed config\n│   ├── app.ts                 ← Hono root app\n│   ├── routes/\n│   │   ├── api/\n│   │   │   ├── content.ts | types.ts | media.ts | users.ts | roles.ts\n│   │   │   ├── menus.ts | settings.ts | forms.ts | redirects.ts\n│   │   │   ├── webhooks.ts | search.ts | analytics.ts | auth.ts\n│   │   │   ├── jobs.ts | audit.ts | schema.ts\n│   │   ├── admin/\n│   │   │   ├── routes.ts\n│   │   │   └── views/         ← JSX server-rendered fragments\n│   │   ├── healthz.ts | metrics.ts | preview.ts\n│   ├── db/\n│   │   ├── client.ts          ← @perryts/mysql pool\n│   │   ├── content.ts | users.ts | roles.ts | media.ts | jobs.ts | …\n│   │   └── migrate.ts\n│   ├── cache/\n│   │   ├── lru.ts | deps.ts | invalidate.ts | persist.ts\n│   ├── render/\n│   │   ├── richtext.tsx       ← TipTap JSON → HTML\n│   │   ├── highlight.ts       ← Shiki at publish time\n│   ├── auth/\n│   │   ├── session.ts | totp.ts | password.ts | ratelimit.ts | tokens.ts\n│   ├── permissions/check.ts\n│   ├── forms/\n│   ├── email/\n│   │   ├── adapter.ts | smtp.ts | resend.ts | postmark.ts | ses.ts\n│   ├── jobs/\n│   │   ├── queue.ts | worker.ts | kinds/\n│   ├── media/\n│   │   ├── upload.ts | imgproxy.ts | storage/local.ts | storage/s3.ts\n│   ├── search/\n│   ├── analytics/\n│   ├── webhooks/\n│   ├── cli/\n│   │   └── main.ts            ← `skelpo-cms \u003csubcommand\u003e`\n│   └── platform/              ← runtime-specific shims\n│       ├── serve.ts | assets.ts | worker.ts\n├── packages/\n│   ├── cms-client/            ← @skelpo/cms-client (SDK)\n│   └── site-kit/              ← @skelpo/site-kit (helpers)\n├── starter/                   ← `skelpo-cms init` copies this\n├── tests/\n├── docker/\n│   └── Dockerfile\n├── .github/workflows/\n├── package.json\n├── tsconfig.json\n└── perry.config.json\n```\n\n---\n\n## Testing\n\n`node:test` via `tsx` — zero extra test deps, runs on Node/Bun/Perry.\n\n```bash\nnpm run test:unit          # pure logic, no DB — runs anywhere (47 tests)\nnpm run test:integration   # full API + admin UI vs a MySQL test DB (34 tests)\nnpm test                   # both — 81 total\n```\n\n- **Unit** (`tests/unit/`): permissions, cache (LRU + dep-graph + ETag),\n  datetime normalization, password hashing, content-writer validation,\n  all of `@skelpo/site-kit`, and the `@skelpo/cms-client` cache/client.\n- **Integration** (`tests/integration/`): `api.test.ts` — auth/ratelimit,\n  content CRUD + publish + SEO-gate + cache + 304, schema evolution,\n  menus/settings/redirects, users/roles, form spam, media\n  alt-enforcement, webhook dispatch, full backup→wipe→restore\n  FK-integrity round-trip. `admin.test.ts` — the HTMX admin: auth gate,\n  login/logout, dashboard, content editor (create/publish/SEO-gate/\n  delete), and every secondary screen incl. their form posts. Each file\n  resets the DB + boots a server; run serially. Auto-skips when no MySQL.\n  (The perry-landing scripts are thin glue over the SDK + site-kit, both\n  exhaustively covered by the suites above.)\n- CI: `.github/workflows/test.yml` — Node 22 \u0026 24, MySQL 8 service,\n  typecheck (all 3 packages) + unit + integration.\n\nThe suite has already caught and fixed three real bugs: an `updateOwn`\nauthorization bypass, a `TRUNCATE`-on-FK-referenced-table restore failure,\nand an empty-JSON-string restore crash.\n\n---\n\n## References\n\n- **`docs/api-spec.md`** — REST API specification (v1)\n- **`docs/schema.md`** — full SQL schema (to be written next)\n- [Perry](https://github.com/PerryTS/perry) — the native TypeScript compiler this is designed for\n- [Hono](https://hono.dev) — the HTTP framework\n- [@perryts/mysql](https://www.npmjs.com/package/@perryts/mysql) — the MySQL driver (pure-TS wire protocol)\n- [TipTap](https://tiptap.dev) — the rich text editor\n- [HTMX](https://htmx.org) — the admin UI mechanism\n- [imgproxy](https://imgproxy.net) — image transforms sidecar\n- [Shiki](https://shiki.style) — syntax highlighting\n\n---\n\n**Next step:** approve this plan, then start scaffolding the `skelpo-cms` package + first migration.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fskelpo%2Fcms","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fskelpo%2Fcms","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fskelpo%2Fcms/lists"}