- SQLite only, no Postgres/MySQL option
- Schema uses JSON columns for flexible field storage
- Media stored as BLOBs in SQLite (keeps single-file simplicity)
- IDs are prefixed random strings:
e_(entries),m_(media),t_(types),s_(sessions)
CREATE TABLE content_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
fields TEXT NOT NULL, -- JSON field definitions
created_at INTEGER DEFAULT (unixepoch())
);CREATE TABLE entries (
id TEXT PRIMARY KEY,
content_type_id TEXT NOT NULL REFERENCES content_types(id),
slug TEXT,
data TEXT NOT NULL, -- JSON field values (includes blocks)
status TEXT DEFAULT 'draft', -- draft | published
version INTEGER DEFAULT 1, -- For collab
published_at INTEGER,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch()),
UNIQUE(content_type_id, slug)
);Entry with blocks (Phase 2+):
{
"title": "My Post",
"content": {
"blocks": [
{"id": "b_1", "type": "heading", "data": {"level": 1, "text": "Hello"}},
{"id": "b_2", "type": "paragraph", "data": {"text": "World"}},
{"id": "b_3", "type": "image", "data": {"media_id": "m_9xk2n"}}
]
},
"featured_image": "m_8xk2n"
}CREATE TABLE media (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
data BLOB NOT NULL,
thumb BLOB,
created_at INTEGER DEFAULT (unixepoch())
);Following Lucia Auth and The Copenhagen Book:
Key principles:
- Session token =
id.secret(separate to prevent timing attacks) - Secret is hashed (SHA-256) before storage
- Sliding expiration (extend active sessions)
- CSRF protection for cookie-based auth
- Invalidate all sessions on password change
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
email_verified INTEGER DEFAULT 0,
password_hash TEXT NOT NULL, -- Argon2id
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
secret_hash BLOB NOT NULL, -- SHA-256 of secret portion
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);Session token format:
id.secret
└─┘ └────┘
│ │
│ └── Random secret (hashed before storage)
└─────── Session ID (stored as-is)
Cookie settings:
HttpOnly: trueSecure: true(HTTPS only in production)SameSite: Lax(CSRF protection)MaxAge: 30 days
CSRF protection:
- Check
Originheader matches expected origin - Reject requests without
Originheader (for non-GET requests) - Use
SameSite=Laxcookies as additional layer
- Uses
std.http.Serverfrom Zig stdlib - Partial page loads via
X-Partialheader (HTMX-style, but handwritten) - Routes return full HTML or fragment based on header
Admin UI:
GET /admin → Dashboard
GET /admin/entries → Entry list
GET /admin/entries/new/:type → New entry form
GET /admin/entries/:id → Edit entry
POST /admin/entries → Create
POST /admin/entries/:id → Update
DELETE /admin/entries/:id → Delete
GET /admin/media → Media library
POST /admin/media → Upload
DELETE /admin/media/:id → Delete
GET /admin/types → Content types
POST /admin/types → Create type
POST /admin/types/:id → Update type
GET /admin/login → Login form
POST /admin/login → Authenticate
POST /admin/logout → Logout
Public API:
GET /api/content/:type → List published
GET /api/content/:type/:slug → Single entry
GET /api/media/:id → Serve media
GET /api/media/:id/thumb → Thumbnail
WebSocket (Phase 3):
WS /ws/collab/:entry_id → Real-time collaboration
- Zig functions that return
[]u8 - Use
std.fmt.allocPrintandArrayList(u8).writer() - No template language — just Zig string formatting
Astro-compatible .publr template format with hybrid parsing (hard requirement):
| Mode | Behavior | Use Case |
|---|---|---|
| Development | Runtime parsing — read .publr from disk each request |
Instant hot reload |
| Production | Comptime parsing — templates compiled into binary | Zero overhead |
---
const post = try ctx.db.getEntry(ctx.params.slug);
---
<article>
<h1>{post.data.title}</h1>
{for (post.data.tags) |tag| (
<span class="tag">{tag}</span>
)}
</article>
- No React, no Vue, no framework
contenteditablefor rich text (no external editor)- ~300 lines of vanilla JS handles: partial loads, form submissions, media picker, toasts
- Plain CSS, no Tailwind, no preprocessor
Plugins are comptime only — managed via CLI, compiled into binary.
.{
.name = "my-site",
.plugins = .{
.code_field = .{
.version = "1.0.0",
.source = "github:publr/code-field",
},
},
}pub const Plugin = struct {
name: []const u8,
field_types: []const FieldType = &.{},
routes: []const Route = &.{},
hooks: []const Hook = &.{},
admin_nav: []const NavItem = &.{},
static_assets: []const Asset = &.{},
};Principle: Install Publr, everything works. No SMTP config required.
Single-admin self-hosted CMS doesn't need email. User has shell access:
publr user reset-password admin@example.com
publr user recovery-link admin@example.comDefault relay service — password reset works out of box:
- Free tier: 100 emails/month
- Overridable with own provider (Resend, SendGrid, SMTP)
| Metric | Target |
|---|---|
| Binary size | < 5MB |
| Startup time | < 100ms |
| Memory (idle) | < 50MB |
| Requests/sec | 1000+ (simple reads) |
| Template hot reload | < 50ms |