@alufie/cms
v0.1.2
Published
Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.
Maintainers
Readme
@alufie/cms
Svelte 5 CMS primitives for SvelteKit with host-delegated authorization, server-first document loading, portable Drizzle schemas, and a sharp-based image upload pipeline.
This package is intentionally small:
- Svelte 5 runes only
- no bundled auth
- no client-side initial fetch requirement
- read-only rendering path with editing fully disabled
- ESM exports designed for tree shaking
- Tailwind utility classes instead of packaged CSS
- Valibot-powered document validation
- registry-driven block rendering and creation
What this package contains
@alufie/cms ships four groups of building blocks:
CmsEditor- standard blocks:
Hero,ImageBlock,RichText - portable Drizzle schemas for PostgreSQL and SQLite
- a server-side
createImageUploadHandlerutility for sharp + R2 style storage - Valibot schemas plus parsing helpers for document validation
- a registry layer for extending and constraining available blocks
- versioning and publish workflow helpers
- a debounced autosave utility for host-side persistence
- document diff helpers for review and publish confirmation flows
- host-driven image upload wiring for
ImageBlock - normalization helpers for safer document upgrades
Security model
This package does not make authorization decisions.
- It does not include auth providers, session libraries, or token helpers.
- The host app must decide whether a user can edit in
hooks.server.ts,+layout.server.ts,+page.server.ts, or action handlers. editable={false}is a hard read-only mode in the UI layer:- the toolbar is not rendered
- block mutation handlers early-return
- the rich text block removes
contenteditable - the editor becomes a pure display renderer
Treat editable as a convenience for the UI, not as the final security boundary. All writes and uploads still need host-side authorization checks.
Tailwind requirement
The components are styled with Tailwind utility classes and do not ship CSS files.
In the host app, make sure Tailwind scans this package so the utilities are included in the final build.
Example tailwind.config.ts:
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/**/*.{html,js,svelte,ts}',
'./node_modules/@alufie/cms/dist/**/*.{js,svelte}'
],
theme: {
extend: {}
},
plugins: []
};
export default config;Installation
pnpm add @alufie/cms
pnpm add -D tailwindcss
pnpm add drizzle-orm sharpNotes:
drizzle-ormis only needed if you use the bundled schemas.sharpis only needed if you use the upload handler.- The package is published as ESM and is tree-shake friendly.
Quick start
1. Load the document on the server
Do not fetch the initial document on the client. Load it in SvelteKit server code and pass it directly into the page.
// src/routes/cms/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, locals }) => {
const page = await locals.db.query.cmsDocuments.findFirst({
where: (table, { eq }) => eq(table.slug, params.slug)
});
if (!page) {
throw error(404, 'Page not found');
}
const editable = locals.user?.role === 'admin' || locals.user?.role === 'editor';
return {
document: page.document,
editable
};
};2. Render the editor in the page
<!-- src/routes/cms/[slug]/+page.svelte -->
<script lang="ts">
import { CmsEditor, type CmsDocument } from '@alufie/cms';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let document = $state<CmsDocument>(data.document);
$effect(() => {
document = data.document;
});
</script>
<CmsEditor
data={document}
editable={data.editable}
uploadImage={async (file) => {
const formData = new FormData();
formData.set('file', file);
const response = await fetch('/api/cms/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
return {
src: result.src,
width: result.width,
height: result.height
};
}}
onChange={(next) => {
if (!data.editable) return;
document = next;
}}
/>3. Save changes through a host-controlled action or endpoint
// src/routes/cms/[slug]/+page.server.ts
import { fail } from '@sveltejs/kit';
export const actions = {
save: async ({ request, locals, params }) => {
if (locals.user?.role !== 'admin' && locals.user?.role !== 'editor') {
return fail(403, { message: 'Forbidden' });
}
const { document } = await request.json();
await locals.db
.update(locals.schema.cmsDocuments)
.set({
document,
updatedAt: new Date()
})
.where(locals.eq(locals.schema.cmsDocuments.slug, params.slug));
return { ok: true };
}
};File structure
src/lib/
blocks/
Hero.svelte
ImageBlock.svelte
RichText.svelte
index.ts
components/
CmsEditor.svelte
db/
index.ts
pg.ts
shared.ts
sqlite.ts
server/
index.ts
uploadHandler.ts
index.ts
types.tsPublic exports
You can import from the root entrypoint or from narrow subpaths.
Root imports
import {
CmsEditor,
Hero,
ImageBlock,
RichText,
pgCmsDocuments,
sqliteCmsDocuments,
createImageUploadHandler
} from '@alufie/cms';Narrow imports
Prefer narrow imports when you know exactly what you need.
import CmsEditor from '@alufie/cms/CmsEditor';
import Hero from '@alufie/cms/blocks/Hero';
import { createCmsAutosave } from '@alufie/cms/autosave';
import { diffCmsDocuments, summarizeCmsDocumentDiff } from '@alufie/cms/diff';
import { createHeroBlock } from '@alufie/cms/factories';
import { normalizeCmsDocument } from '@alufie/cms/normalize';
import { defaultCmsBlockRegistry } from '@alufie/cms/registry';
import { pgCmsDocuments } from '@alufie/cms/db/pg';
import { createImageUploadHandler } from '@alufie/cms/server/uploadHandler';
import { publishCmsDocument } from '@alufie/cms/server/workflow';
import { parseCmsDocument } from '@alufie/cms/validation';
import type { CmsDocument } from '@alufie/cms/types';Tree shaking and package weight
The package is set up to keep unused code out of consumer bundles:
"sideEffects": falseinpackage.json- ESM-only exports
- split subpath exports for components, blocks, db, server, and types
- no global CSS import
- no bundled auth implementation
To get the best result:
- prefer narrow subpath imports for specialized use cases
- keep server-only imports in server files
- only install
drizzle-ormandsharpif you use those features
Versioning and publish workflow
The package now includes storage-agnostic workflow helpers for:
- draft saves
- publish transitions
- archive transitions
- restore transitions
- version snapshot creation
These helpers do not write to the database for you. They validate and shape the next document/version payloads so the host app stays in control.
Create a draft save and version snapshot
import { updateCmsDocument } from '@alufie/cms/server/workflow';
const result = updateCmsDocument({
document: payload.document,
version: currentVersion + 1,
createdBy: locals.user.id
});
await db.transaction(async (tx) => {
await tx.update(cmsDocuments).set(result.document).where(...);
await tx.insert(cmsDocumentVersions).values(result.version);
});Publish a document
import { publishCmsDocument } from '@alufie/cms/server/workflow';
const result = publishCmsDocument({
document: payload.document,
version: currentVersion + 1,
createdBy: locals.user.id
});Review and diff helpers
The package now includes diff helpers so the host app can generate review summaries before saving or publishing.
Compare two documents
import { diffCmsDocuments } from '@alufie/cms/diff';
const diff = diffCmsDocuments(previousDocument, nextDocument);
console.log(diff.counts);
console.log(diff.items);Generate user-facing change summaries
import { summarizeCmsDocumentDiff } from '@alufie/cms/diff';
const summary = summarizeCmsDocumentDiff(previousDocument, nextDocument);
// Example:
// [
// 'Title changed',
// 'Moved hero (block-123) from position 1 to 2',
// 'Updated richText (block-456)'
// ]Use a diff in a publish review step
import { publishCmsDocument } from '@alufie/cms/server/workflow';
import { summarizeCmsDocumentDiff } from '@alufie/cms/diff';
const reviewSummary = summarizeCmsDocumentDiff(currentDocument, incomingDocument);
const publishResult = publishCmsDocument({
document: incomingDocument,
version: currentVersion + 1,
createdBy: locals.user.id
});Archive or restore a document
import { archiveCmsDocument, restoreCmsDocument } from '@alufie/cms/server/workflow';Version table schemas
The Drizzle package surface now includes version tables:
pgCmsDocumentVersionssqliteCmsDocumentVersions
These tables store:
- document id
- numeric version
- reason (
draft-save,publish,archive,restore,manual) - full document snapshot
- created-at metadata
- created-by metadata
Autosave guide
The package now includes a small debounced autosave helper for host-managed persistence.
Create an autosave controller
import { createCmsAutosave } from '@alufie/cms/autosave';
const autosave = createCmsAutosave({
delayMs: 1000,
save: async (document) => {
await fetch('/api/cms/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ document })
});
},
onStateChange: (state) => {
console.log(state.pending, state.lastSavedAt, state.lastError);
}
});Queue changes from the editor
<script lang="ts">
import { CmsEditor } from '@alufie/cms';
import { createCmsAutosave } from '@alufie/cms/autosave';
let { data } = $props();
let document = $state(data.document);
const autosave = createCmsAutosave({
save: async (next) => {
await fetch('/api/cms/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ document: next })
});
}
});
</script>
<CmsEditor
data={document}
editable={data.editable}
onChange={(next) => {
document = next;
autosave.queue(next);
}}
/>Editor validation UX
When validateOnChange={true}, the editor now keeps invalid edits from being committed and can render a visible validation panel above the canvas.
<CmsEditor
data={document}
editable={data.editable}
validateOnChange={true}
showValidationIssues={true}
onInvalidDocument={(issues) => {
console.error(issues);
}}
/>If you prefer to manage validation feedback entirely in the host app, set showValidationIssues={false} and use onInvalidDocument.
Image upload UI
ImageBlock can consume a host-provided uploadImage(file) callback through CmsEditor.
<CmsEditor
data={document}
editable={data.editable}
uploadImage={async (file) => {
const formData = new FormData();
formData.set('file', file);
const response = await fetch('/api/cms/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
return {
src: result.src,
width: result.width,
height: result.height
};
}}
/>The package only uses the returned src, width, and height. Auth, rate limiting, and storage decisions still belong to the host app.
Normalization guide
Use normalization helpers when importing legacy content, seeding documents, or migrating payloads between versions.
import { normalizeCmsDocument } from '@alufie/cms/normalize';
const document = normalizeCmsDocument(unknownPayload);Current normalization behavior:
- fills missing document defaults
- fills missing built-in block fields
- upgrades legacy
richTextstring data to{ text: string }
Validation guide
The package now ships Valibot schemas and helpers so the host app can validate incoming documents before save or publish.
Validate a document on the server
import { parseCmsDocument } from '@alufie/cms/validation';
const document = parseCmsDocument(requestPayload);Safe-parse a document and return structured errors
import { formatCmsValidationIssues, safeParseCmsDocument } from '@alufie/cms/validation';
const result = safeParseCmsDocument(requestPayload);
if (!result.success) {
return {
ok: false,
errors: formatCmsValidationIssues(result.issues)
};
}Validate against a custom registry
import { createCmsBlockRegistry, defaultCmsBlockRegistry } from '@alufie/cms/registry';
import { safeParseCmsDocument } from '@alufie/cms/validation';
const registry = createCmsBlockRegistry({}, defaultCmsBlockRegistry);
const result = safeParseCmsDocument(payload, registry);Registry guide
The editor no longer hardcodes its block list. It now uses a registry object that defines:
- which block types exist
- which component renders each block
- how new blocks are created
- which Valibot schema validates each block's data
Built-in registry
import { defaultCmsBlockRegistry } from '@alufie/cms/registry';Restrict the editor to specific block types
<CmsEditor
data={document}
editable={data.editable}
allowedBlockTypes={['hero', 'richText']}
/>Validate on each change
<CmsEditor
data={document}
editable={data.editable}
validateOnChange={true}
onInvalidDocument={(issues) => {
console.error(issues);
}}
/>Add a custom block
import type { CmsBlockDefinition } from '@alufie/cms/types';
import { createCmsBlockId } from '@alufie/cms/factories';
import { createCmsBlockRegistry, defaultCmsBlockRegistry } from '@alufie/cms/registry';
import * as v from 'valibot';
import QuoteBlock from '$lib/components/QuoteBlock.svelte';
const quoteBlockDefinition = {
type: 'quote',
label: 'Quote',
component: QuoteBlock,
create: () => ({
id: createCmsBlockId(),
type: 'quote',
data: {
quote: '',
attribution: ''
}
}),
schema: v.object({
quote: v.string(),
attribution: v.string()
})
} satisfies CmsBlockDefinition;
export const cmsRegistry = createCmsBlockRegistry({
quote: quoteBlockDefinition
}, defaultCmsBlockRegistry);Then pass that registry into the editor:
<CmsEditor data={document} editable={data.editable} registry={cmsRegistry} />Data model
CmsDocument
type CmsDocument = {
id?: string;
title?: string;
blocks: CmsBlock[];
updatedAt?: string;
};CmsBlock
type CmsBlock = CmsHeroBlock | CmsImageBlock | CmsRichTextBlock;CmsHeroBlock
type CmsHeroBlock = {
id: string;
type: 'hero';
data: {
eyebrow: string;
title: string;
summary: string;
ctaLabel: string;
ctaHref: string;
align: 'left' | 'center';
};
};CmsImageBlock
type CmsImageBlock = {
id: string;
type: 'image';
data: {
src: string;
alt: string;
caption: string;
width: number | null;
height: number | null;
};
};CmsRichTextBlock
type CmsRichTextBlock = {
id: string;
type: 'richText';
data: {
text: string;
};
};Component guide
CmsEditor
File: src/lib/components/CmsEditor.svelte
Responsibilities:
- receives a server-provided
CmsDocument - clones the input into local state
- renders blocks in order
- renders toolbar and block controls only when
editable === true - blocks all mutations when
editable === false - emits
onChange(document)when a block changes
Props:
type CmsEditorProps = {
data: CmsDocument;
editable?: boolean;
uploadImage?: (file: File) => Promise<{ src: string; width?: number | null; height?: number | null }>;
onChange?: (document: CmsDocument) => void;
};Key internal functions:
cloneDocument(value): creates a safe mutable copy of the incoming documentcloneBlock(block): preserves block discriminated union typing while cloning block datacommit(blocks): replaces editor state and emitsonChangecreateId(): generates a UUID for newly inserted blockscreateBlock(type): creates a default block from a built-in templateinsertBlock(type, index?): inserts a new blockupdateBlock(index, block): replaces a block after child editsmoveBlock(index, direction): reorders blocksduplicateBlock(index): clones a block and inserts it after the originalremoveBlock(index): deletes a blockregistryprop: controls which blocks exist, render, validate, and can be insertedvalidateOnChangeprop: runs Valibot validation before emittingonChangeallowedBlockTypesprop: limits toolbar insertion choices without changing the document modeluploadImageprop: lets the host wire authenticated image uploads intoImageBlock
Hero
File: src/lib/blocks/Hero.svelte
Responsibilities:
- renders hero content
- exposes form inputs when editable
- falls back to semantic read-only display when not editable
ImageBlock
File: src/lib/blocks/ImageBlock.svelte
Responsibilities:
- renders image metadata inputs in edit mode
- renders the image and caption in both modes
- supports width and height metadata for layout stability
- optionally uploads a local image through the host callback
RichText
File: src/lib/blocks/RichText.svelte
Responsibilities:
- uses
contenteditableonly in edit mode - becomes plain read-only text when not editable
- stores plain text, which keeps rendering safe by default
Drizzle schema guide
Shared constants
File: src/lib/db/shared.ts
CMS_DOCUMENT_TABLECMS_DOCUMENT_COLUMNSCMS_DOCUMENT_VERSION_TABLECMS_DOCUMENT_VERSION_COLUMNSCMS_DOCUMENT_STATUSESCMS_VERSION_REASONS
These keep table naming and column naming consistent across SQLite and PostgreSQL adapters.
PostgreSQL schema
File: src/lib/db/pg.ts
Export:
pgCmsDocumentspgCmsDocumentVersions
Shape:
id: primary keyslug: unique route identifiertitle: document titledocument: JSONB payloadstatus: draft/published style statecreatedAtupdatedAt
SQLite schema
File: src/lib/db/sqlite.ts
Export:
sqliteCmsDocumentssqliteCmsDocumentVersions
This mirrors the PostgreSQL schema, but stores document as JSON text and timestamps as timestamp_ms integers.
Upload handler guide
File: src/lib/server/uploadHandler.ts
Export:
createImageUploadHandler(options)
Purpose:
- validate input file type and size
- normalize rotation
- enforce hard image dimension limits
- resize for delivery
- convert to webp
- upload through a host-supplied storage adapter
- return metadata ready for the CMS document
Function signature
type CreateImageUploadHandlerOptions = {
maxBytes?: number;
maxWidth?: number;
maxHeight?: number;
allowedMimeTypes?: readonly string[];
makeKey?: (file: UploadFileLike) => string;
putObject: (params: {
key: string;
body: Buffer;
contentType: string;
cacheControl: string;
}) => Promise<{ src: string } | string>;
};Example with an R2-style client
import { createImageUploadHandler } from '@alufie/cms/server/uploadHandler';
export const uploadImage = createImageUploadHandler({
putObject: async ({ key, body, contentType, cacheControl }) => {
await env.BUCKET.put(key, body, {
httpMetadata: {
contentType,
cacheControl
}
});
return {
src: `https://cdn.example.com/${key}`
};
}
});How to expand the package
Add a new block type
- create a new block component
- define a block definition with
type,label,component,create, andschema - merge it into the registry with
createCmsBlockRegistry - pass that registry into
CmsEditor - validate documents with
safeParseCmsDocument(payload, registry)
Use the factory helpers
Factory helpers make it easier to create consistent content programmatically:
import { createCmsDocument, createHeroBlock, createImageBlock } from '@alufie/cms/factories';
const document = createCmsDocument({
title: 'Home',
blocks: [
createHeroBlock({ title: 'Welcome' }),
createImageBlock({ src: 'https://cdn.example.com/hero.webp' })
]
});Add richer persistence
- keep the editor document format stable
- add migration logic in the host app when block structure changes
- version documents in your own schema if backward compatibility matters
Add richer text semantics
Right now RichText stores plain text for safety and predictable rendering. If you need structured rich text:
- define a structured document model in
types.ts - replace the current block implementation
- keep the
editable={false}path free of mutation hooks - sanitize any HTML rendering in the host app or in a dedicated renderer
Recommended host integration pattern
- authorize once on the server
- load the document in
+page.server.ts - pass
documentandeditableinto the page - render
CmsEditor - save via server actions or
+server.ts - re-check role permissions on every write
Development
pnpm install
pnpm check
pnpm buildCurrent package status
The scaffold currently provides:
- Svelte 5 runes-only components
- Tailwind utility styling
- read-only safe rendering path
- portable Drizzle schemas
- document version table schemas
- publish/archive/restore workflow helpers
- debounced autosave helper
- document diff and review helpers
- inline validation issue rendering in the editor
- image upload callback support in
ImageBlock - normalization helpers for legacy content
- image upload helper
- narrow exports for tree shaking
It does not yet provide:
- persistence actions
- auth implementation
- image picker UI
- collaborative editing
- structured rich text schema
