highseam
v1.7.5
Published
Config-driven CMS for SvelteKit that runs at the edge and sits on top of the database you already have. One cms.config.ts generates the data layer (schema-less or your existing D1 tables), REST + GraphQL APIs, and a full admin panel — drafts, localization
Maintainers
Readme
highseam
highseam.com · npm · CHANGELOG · ROADMAP
A config-driven CMS for SvelteKit that runs at the edge and can sit on top of the database you already have.
One src/cms.config.ts in, three things out: a data layer (schema-less
documents, or column-per-field over your existing D1 tables), REST + GraphQL
APIs, and a full admin panel. The core is framework-agnostic where it counts
— the same engine runs on Node and Cloudflare Workers (D1 + R2), with zero
SvelteKit imports.
- Collections, globals, drafts + version history, scheduled publishing, document locking, localization, hooks, plugins, generated TS types, and access control down to query constraints.
- Structured rich text with images, document references, and your own typed components inside the flow — sanitized server-side.
- A media library where one original is the source of truth: focal points with live aspect previews, in-context editing from any image chip, per-placement presentation, provenance-aware bulk imports.
- Themes are token files (default: lodestone). Route stubs are yours to customise; upgrades are drift-checked.
Install
In any SvelteKit project:
npm i highseam
npx highseam-init # scaffolds routes, hooks, cms.config.ts, theme
npm run dev # → /admin, create the first userThe scaffolder copies thin route stubs (REST + GraphQL + admin + media) that
import from the package — delete what you don't need, customize what you do.
Everything is driven by your src/cms.config.ts.
Gotchas:
- Don't nest the CMS app inside another pnpm project (e.g.
app/cms/): pnpm hoists resolution to the parent and the package's deps (graphql) won't resolve. Scaffold a sibling directory instead. - Table-backed collections + local dev: your LOCAL D1 starts empty, so a
collection mapped to an existing remote table will report "table does not
exist" until you seed it:
npx wrangler d1 execute <db-name> --local --file schema.sql. - Consumer Tailwind must scan the package. The scaffolded
highseam.cssincludes@source "../node_modules/highseam/dist"— Tailwind v4 doesn't scannode_modules, so without it the admin's utilities (tabs, grids, picker) don't generate and the layout collapses. If you scaffolded before 0.4.0, add that line. - Don't name your app
highseam— npm/pnpm refuse a dependency whose name matches the host package. - Bump the tarball filename each release (
highseam-0.4.0.tgz, …). In-placefile:tarball swaps under the same name trip pnpm's lockfile integrity check. - Dep-scan warnings on install: some bundlers' dependency scanners log
UNRESOLVED_IMPORTfor$app/*imports inside highseam's packaged admin components. Cosmetic — SvelteKit resolves those aliases at build time and builds pass. Silence it withoptimizeDeps: { exclude: ['highseam'] }in vite config if it bothers you. - Engine updates arrive via
npm i highseam@<version>; scaffolded stubs are yours and don't auto-update. After upgrading, runnpx highseam-init --check-stubs— it reports any template lines your stubs are missing (your customisations are never flagged) and exits non-zero, so it can gate a deploy. Releases that touch stubs name the files in the CHANGELOG under "Stub changes".
Quick start (this repo — the demo app)
npm install
npm run devOpen http://localhost:5173/admin — you'll be prompted to create the first user (they become an admin). Or load demo content first:
curl -X POST http://localhost:5173/api/seed \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"password123"}'How it works
Everything is driven by src/cms.config.ts — the equivalent of
payload.config.ts. Declare a collection there and it automatically gets:
- a database presence (no migrations needed)
- REST endpoints at
/api/<slug>with auth, validation, and access control - a full admin UI at
/admin/<slug>(list, search, sort, paginate, create, edit, delete)
const posts = defineCollection({
slug: 'posts',
admin: { useAsTitle: 'title', defaultColumns: ['title', 'status', 'author'] },
access: {
// Access functions can return booleans OR query constraints:
read: ({ req }) => (req.user ? true : { status: { equals: 'published' } }),
create: ({ req }) => Boolean(req.user)
},
hooks: {
beforeValidate: [({ data }) => { /* auto-slug, stamp publishedAt, ... */ }]
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true },
{ name: 'status', type: 'select', options: [/* … */], defaultValue: 'draft' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'content', type: 'richText' }
]
});Field types: text, textarea, email, number, checkbox, date,
select (+hasMany), relationship (+hasMany), upload, richText
(structured nodes), html (sanitized), blocks, array, json.
REST API
GET /api/posts?where[status][equals]=published&limit=10&page=1&sort=-createdAt&depth=1
POST /api/posts
GET /api/posts/:id
PATCH /api/posts/:id
DELETE /api/posts/:id
POST /api/users/login # sets httpOnly cookie, returns JWT
POST /api/users/logout
GET /api/users/meWhere operators: equals, not_equals, contains, in, greater_than,
less_than, exists. depth controls relationship population (related docs are
only populated if the requester has read access to them).
Local API
Server-side, skip HTTP entirely:
const posts = await locals.cms.find({ user: locals.user }, 'posts', {
where: { featured: { equals: true } }
});
await locals.cms.create({ user: null }, 'posts', data, { overrideAccess: true });Architecture
src/cms.config.ts ← your collections (the only file you usually touch)
src/lib/core/ ← framework-agnostic engine (zero SvelteKit imports)
types.ts ← field/collection/access/hook types
cms.ts ← Local API: CRUD + access + hooks + population
validate.ts ← config-driven validation & coercion
crypto.ts ← Web Crypto only: PBKDF2 passwords, HS256 JWTs
adapters/memory.ts ← in-memory/JSON-file adapter (dev)
adapters/d1.ts ← Cloudflare D1 adapter (prod, schema-less JSON docs)
src/lib/server/ ← SvelteKit wiring (adapter selection, cookies)
src/lib/admin/ ← admin UI components (rendered from config)
src/routes/api/... ← generated REST endpoints
src/routes/admin/... ← generated admin panelThe core uses only Web Crypto (no bcrypt, no jsonwebtoken, no Node built-ins),
so it runs identically in vite dev, Node, and Cloudflare Workers. Storage is an
adapter interface — JSON file in dev (.data/db.json), D1 in production; swapping
databases never touches collection code.
Deploying to Cloudflare
wrangler d1 create highseam-db # then uncomment the binding in wrangler.toml
npm run deploySet a strong CMS_SECRET (Pages env var). The CMS auto-detects the D1 binding.
Live preview (two separate apps, one database)
Add admin.preview.url(doc, token) to a collection — it returns the public
URL the admin shows in a preview pane (form left, resizable iframe right; the
function runs server-side so the CMS never learns your route shape). The admin
mints a short-lived token signed with CMS_SECRET; your public site (a
separate deploy sharing the DB) verifies it and renders unpublished content:
// public site — src/routes/[type]/[slug]/+page.server.ts
import { verifyPreviewToken } from 'highseam/preview';
const claims = await verifyPreviewToken(env.CMS_SECRET, url.searchParams.get('token'));
const doc = claims ? await readDraft(claims.collection, claims.id) : await readPublished(slug);Two modes: the iframe reloads on save (Mode A), and the form postMessages the
in-progress doc as you type for true live preview (Mode B) — subscribe with
onPreviewData(cb). Drop <PreviewBanner/> (from
highseam/PreviewBanner.svelte) into the previewed page. See
src/routes/preview/venues/[id] for a working reference.
Admin: theming, tabs, filters, bulk
- Theme —
admin.theme: 'light' | 'dark' | 'system'. The built-in dark theme is lodestone (a lode is a seam of ore; a lodestone is the miners' compass). A highseam theme is nothing more than a CSS token file — seehighseam/themes/lodestone.cssfor the canonical documented reference: copy it, change values, import afterhighseam.css. Every color is a--color-*variable; the editor surface is its own--hs-canvastoken. - Form layout —
admin.groupon fields +admin.groupsAs: 'tabs'turns a 60-field form into tabs;admin.position: 'sidebar'pulls meta fields into a side column. - Filters —
admin.filters: [...]renders faceted, combinable controls (select / boolean / has-content / contains) that buildwherequeries, URL-synced. Straight SQL for table-backed collections. - Bulk — select rows → publish / unpublish / delete / run a document action across the selection.
Table-backed collections (sit on top of an existing database)
The D1 adapter has two modes. Default is schema-less JSON documents; add
table to a collection and it maps onto an existing SQL table, one
column per field (the Directus model) — your pipeline-owned relational data
gets the same admin, REST, and GraphQL as everything else:
const venues = defineCollection({
slug: 'venues',
table: { name: 'venues', autoId: true,
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } },
fields: [/* names must match column names */]
});Where/sort compile to plain SQL, checkbox fields marshal to 0/1, and
array/blocks/json/richText fields marshal to JSON text columns. Integer
autoincrement and uuid ids both supported; timestamps optional. (Drafts
require a _status column; localized fields and upload collections aren't
supported in table mode.) In dev the collection runs on the JSON store.
HTML fields
type: 'html' stores a sanitized HTML string — built for content produced
as HTML by external pipelines (AI writers, migrations). Server-side
sanitizer strips scripts/styles/event handlers and unsafe URLs on every
write; the admin edits it with a WYSIWYG (visual + HTML source modes). Use
alongside structured richText, each where it fits.
Drafts & versions
Add versions: { drafts: true } to a collection and it gets a managed
_status ('draft' | 'published'), full version history in a shadow store, and
a draft workflow in both APIs and the admin (Save draft / Publish / Unpublish /
Restore, with status badges and a versions sidebar):
PATCH /api/posts/:id?draft=true # save a draft; published doc untouched
GET /api/posts/:id?draft=true # read the latest draft (update access)
POST /api/posts/:id/publish # promote latest draft
POST /api/posts/:id/unpublish # hide from published-only readers
GET /api/posts/:id/versions # history, newest first
POST /api/posts/:id/versions/:vid/restoreCombined with query-constraint access control
(read: ({ req }) => req.user ? true : { _status: { equals: 'published' } }),
anonymous consumers never see unpublished content.
Blocks
The blocks field is a flexible layout builder: an ordered list of
typed sections. Declare block shapes in config; the admin renders an
interactive editor (add / remove / reorder, per-block fields). Values store as
[{ blockType: 'hero', ... }, { blockType: 'quote', ... }] — see the pages
collection's layout field for a working example with four block types.
Media & uploads
Mark a collection upload: true (optionally with mimeTypes / maxFileSize)
and its documents carry a stored file with managed filename, mimeType,
filesize, and url fields. Files live on disk in dev (.data/uploads) and
in R2 in production (uncomment the bucket in wrangler.toml), served from
/media/<key> with immutable cache headers. Reference uploads from other
collections with the upload field type — they populate like relationships:
One original is the source of truth. Declare a focalPoint field and the
editor becomes a hotspot picker with live crop previews per registered aspect
(media.aspects in config); every thumbnail crops toward the hotspot;
mediaSrc() builds Cloudflare Image Resizing URLs with gravity from the
focal point. Any image chip opens the media doc's edit surface in a modal —
no page round trip. Rich-text placements can choose a per-use aspect without
touching the original. Bulk imports (cms.importMedia /
POST /api/<slug>/import) are provenance-aware: externalKey dedupe, adopted
storage keys, per-item outcomes.
curl -b cookies -X POST /api/media -F '[email protected]' -F 'alt=A photo'Globals
Singletons (site settings, nav, footer) declared under globals in the
config. GET/POST /api/globals/<slug>, edited in the admin under their own
sidebar section, with the same field types and access control as collections.
Generated TypeScript types
npm run generate:types (dev server running) writes src/generated-types.ts
from your config — select fields become literal unions, blocks become
discriminated unions on blockType, relationships/uploads become
string | Related.
GraphQL
POST /api/graphql with an auto-generated schema: per-collection queries
(Posts(where, limit, page, sort), Post(id, draft)), mutations
(createPost, updatePost, deletePost, publishPost, unpublishPost),
globals, login, and me. Relationship fields resolve lazily, so query depth
follows your GraphQL selection set. Same access control as REST.
{ Posts { docs { title category { title } author { email } } } }Plugins
A plugin is a config transform, applied in order by
defineConfig. See src/lib/plugins/last-modified-by.ts — it injects a
read-only field plus a beforeChange hook into chosen collections.
API keys
auth: { apiKeys: true } issues each user a key at creation (visible only to
themselves and admins). Authenticate any REST or GraphQL call with:
Authorization: users API-Key <key>Autosave
Drafts-enabled collections autosave in the admin: edits debounce into background draft saves (published doc untouched), with a status indicator.
Rich text (structured, with embedded components)
richText fields store a JSON array of typed nodes — the Lexical-equivalent.
Text blocks carry sanitized inline HTML (server-enforced whitelist:
strong/em/code/a/s/u); everything else is a first-class node INSIDE the
document flow:
{
name: 'content', type: 'richText',
uploads: 'media', // embeddable images
relationships: ['posts'], // embeddable document references
blocks: [calloutBlock, ctaBlock] // typed components, defined in config
}The admin renders a block editor: contenteditable text blocks with a formatting toolbar, plus add/reorder/delete for lists, quotes, code, dividers, images, embeds, and your components — each component edits its own typed fields inline.
Built for the paste-then-reformat loop. Paste a document (Word, Docs, HTML, an
AI draft) and it lands as one node per block; then Enter splits a node at
the caret, Backspace at a node's start merges it back, and selecting part
of a paragraph and clicking H2 promotes just that selection — splitting the
node around it. Markdown prefixes (## , > , - , 1. , ---) convert an
empty paragraph as you type, ⌘/Ctrl+⌥+1/2/3 set the focused node's type, and
⌘/Ctrl+Shift+V pastes as plain text. $lib/RichTextRenderer.svelte renders it on the frontend
(/posts/<slug> is a working example, with extractRichTextRefs resolving
embedded documents server-side). Legacy markdown strings still work.
Localization
Set localization: { locales: ['en', 'es'], defaultLocale: 'en' } and mark
fields localized: true. Values are stored per-locale and flattened on read
with default-locale fallback. ?locale=es on any REST endpoint (or the
locale arg in GraphQL) targets that locale for reads AND writes;
locale=all returns the raw per-locale objects. Filtering and unique checks
are locale-aware, and the admin gets a locale switcher on edit pages.
Scheduled publishing
With versions: { drafts: true, schedulePublish: true }, drafts whose
publishAt date has passed are auto-promoted to published (swept lazily on
reads — no job runner needed).
Document locking
Opening a document in the admin takes a 5-minute rolling edit lock. A second editor sees a warning banner naming the current editor; locks release on save/publish/delete or expire on their own.
Scope: what's in, what's not
In: config-driven collections, the full field-type set (including blocks,
upload, structured richText, sanitized html), auth collections + API
keys, access control (boolean + query-constraint), hooks (beforeValidate,
beforeChange, afterChange, afterRead, beforeDelete, afterDelete),
unique fields, defaults, validation, pagination/filtering/sorting,
relationship population with depth, drafts & version history (save draft
/ publish / unpublish / restore / prune), autosave, scheduled
publishing, document locking, localization (per-field, locale
fallback, locale-aware queries), media (fs/R2 storage, focal points +
aspect previews, in-context editing, mediaSrc() transforms, provenance
imports), globals, generated TS types, GraphQL, plugins,
first-user onboarding, REST + Local APIs, table-backed collections over
existing D1 tables, auto-generated admin.
Not yet (see ROADMAP): gallery + embed nodes, repeater UI for array fields, upload dedupe, link fields, named collection queries. Not planned near-term: jobs/queues, email flows (verification / forgot-password), multi-tenancy, admin i18n, collaborative cursors.
Lineage, honestly: highseam began as a study of Payload's config-driven architecture and grew into its own thing — Sanity-flavoured editing, a Directus-flavoured bring-your-own-tables mode, and a media model of its own. The config surface will feel familiar to Payload users on purpose.
