npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@alufie/cms

v0.1.2

Published

Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.

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:

  1. CmsEditor
  2. standard blocks: Hero, ImageBlock, RichText
  3. portable Drizzle schemas for PostgreSQL and SQLite
  4. a server-side createImageUploadHandler utility for sharp + R2 style storage
  5. Valibot schemas plus parsing helpers for document validation
  6. a registry layer for extending and constraining available blocks
  7. versioning and publish workflow helpers
  8. a debounced autosave utility for host-side persistence
  9. document diff helpers for review and publish confirmation flows
  10. host-driven image upload wiring for ImageBlock
  11. 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 sharp

Notes:

  • drizzle-orm is only needed if you use the bundled schemas.
  • sharp is 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.ts

Public 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": false in package.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:

  1. prefer narrow subpath imports for specialized use cases
  2. keep server-only imports in server files
  3. only install drizzle-orm and sharp if 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:

  • pgCmsDocumentVersions
  • sqliteCmsDocumentVersions

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 richText string 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 document
  • cloneBlock(block): preserves block discriminated union typing while cloning block data
  • commit(blocks): replaces editor state and emits onChange
  • createId(): generates a UUID for newly inserted blocks
  • createBlock(type): creates a default block from a built-in template
  • insertBlock(type, index?): inserts a new block
  • updateBlock(index, block): replaces a block after child edits
  • moveBlock(index, direction): reorders blocks
  • duplicateBlock(index): clones a block and inserts it after the original
  • removeBlock(index): deletes a block
  • registry prop: controls which blocks exist, render, validate, and can be inserted
  • validateOnChange prop: runs Valibot validation before emitting onChange
  • allowedBlockTypes prop: limits toolbar insertion choices without changing the document model
  • uploadImage prop: lets the host wire authenticated image uploads into ImageBlock

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 contenteditable only 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_TABLE
  • CMS_DOCUMENT_COLUMNS
  • CMS_DOCUMENT_VERSION_TABLE
  • CMS_DOCUMENT_VERSION_COLUMNS
  • CMS_DOCUMENT_STATUSES
  • CMS_VERSION_REASONS

These keep table naming and column naming consistent across SQLite and PostgreSQL adapters.

PostgreSQL schema

File: src/lib/db/pg.ts

Export:

  • pgCmsDocuments
  • pgCmsDocumentVersions

Shape:

  • id: primary key
  • slug: unique route identifier
  • title: document title
  • document: JSONB payload
  • status: draft/published style state
  • createdAt
  • updatedAt

SQLite schema

File: src/lib/db/sqlite.ts

Export:

  • sqliteCmsDocuments
  • sqliteCmsDocumentVersions

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

  1. create a new block component
  2. define a block definition with type, label, component, create, and schema
  3. merge it into the registry with createCmsBlockRegistry
  4. pass that registry into CmsEditor
  5. 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

  1. keep the editor document format stable
  2. add migration logic in the host app when block structure changes
  3. 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:

  1. define a structured document model in types.ts
  2. replace the current block implementation
  3. keep the editable={false} path free of mutation hooks
  4. sanitize any HTML rendering in the host app or in a dedicated renderer

Recommended host integration pattern

  1. authorize once on the server
  2. load the document in +page.server.ts
  3. pass document and editable into the page
  4. render CmsEditor
  5. save via server actions or +server.ts
  6. re-check role permissions on every write

Development

pnpm install
pnpm check
pnpm build

Current 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