@classytic/cms
v0.2.1
Published
Engine-factory CMS package: content types, content items, assets. Block-based (Slate AST), form-based (typed), and MDX content unified in one storage model. Multi-tenant, headless, visibility-aware. PACKAGE_RULES compliant; built on Mongoose + mongokit +
Readme
@classytic/cms
Engine-factory CMS package. Stores content as block-based (Slate AST), form-based (typed shape), or raw MDX in a single unified resource. Built on Mongoose + mongokit + arc primitives.
Why this exists
Most CMS packages force a single content model. A blog post needs paragraphs + headings + embedded blocks (Slate / MDX). A landing-page "About" section needs a typed shape (hero headline, story paragraphs, values list) authored via a form. WordPress, Sanity, Payload all solve this by shipping two parallel systems.
@classytic/cms solves it with one resource (ContentItem) and a per-org ContentType registry that declares the body shape:
displayMode: 'block'→ body is a Slate AST array, edited in a Plate-based editor, optionally compiled to MDX on publish.displayMode: 'form'→ body is a typed object matching a form schema (e.g.@classytic/formkit), edited via form-system.displayMode: 'mdx'→ body is an MDX source string, edited as text or via MDX-aware editor.
The package owns storage + domain logic + events. The host owns HTTP/MCP wiring (via @classytic/arc) and the editor UI.
Resources
| Resource | Purpose |
|---|---|
| ContentType | Per-org content type registry: { key, label, displayMode, formSchema?, allowedBlocks?, routePattern? }. Each org defines its own types (blog, lesson, about-page, faq, etc.). |
| ContentItem | The actual content document: { contentTypeKey, slug, status, content, bodyMdx?, metadata, publishedAt }. content is Schema.Types.Mixed (Slate AST for block mode, form data for form mode, MDX string for mdx mode). |
| Asset | Media library entry: { type, url, storageKey, mime, width, height, duration, altText, sourceRef }. |
Quick start
import { createCms } from '@classytic/cms';
const cms = await createCms({
connection: mongooseConnection,
tenantFieldType: 'objectId',
multiTenant: true,
eventTransport: hostEventBus,
logger: { error: console.error, info: console.log },
});
// Domain verbs on repositories
const item = await cms.repositories.contentItem.createItem(
{ contentTypeKey: 'blog', slug: 'hello-world', content: slateAst },
{ organizationId: orgId, actorId: userId },
);
await cms.repositories.contentItem.publish(
String(item._id),
{},
{ organizationId: orgId, actorId: userId },
);Host integration (arc)
The host's server app wires arc resources around the engine. arc generates CRUD from the mongoose model + mongokit repository — no proxy layer in this package.
// apps/server/src/resources/cms/cms-engine.ts
import { createCms } from '@classytic/cms';
let cms: Awaited<ReturnType<typeof createCms>> | null = null;
export async function ensureCms(connection: Connection, eventTransport: EventTransport) {
if (cms) return cms;
cms = await createCms({ connection, eventTransport, multiTenant: true });
return cms;
}// apps/server/src/resources/cms/content-item.resource.ts
import { defineResource } from '@classytic/arc';
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
import { ensureCms } from './cms-engine.js';
export async function registerContentItemResource(fastify, deps) {
const cms = await ensureCms(deps.connection, deps.eventTransport);
defineResource({
fastify,
name: 'content-item',
prefix: '/content-items',
adapter: createMongooseAdapter(cms.models.ContentItem, cms.repositories.contentItem),
// disableDefaultRoutes: false → arc auto-CRUDs from the repo
});
}Content modes — author UX
| Mode | Stored shape | Editor (host-owned) | Renderer (host-owned) |
|---|---|---|---|
| block | [{ type, children, ...props }] (Slate AST) | Plate or any Slate-based editor wired to the block contract | Compile to MDX on publish, or render Slate directly with custom components |
| form | { hero: {...}, story: {...}, values: [...] } (matches form schema) | @classytic/formkit <FormGenerator schema={contentType.formSchema} /> | Hand-rolled React page that reads the typed shape |
| mdx | "# Heading\n\nSome **MDX** with <Custom />..." (string) | Plain textarea or MDX-aware editor (MDXEditor, etc.) | @mdx-js/mdx compile + render |
The package doesn't ship editors. Hosts pick what fits their domain.
Domain verbs on repositories
Every repository extends Repository<TDoc> from mongokit. Hosts get full CRUD from arc auto-generation. Domain verbs live on the repository:
ContentItemRepository.createItem(input, ctx)— validates againstContentType.displayMode, normalizes body, emitscontent.created.ContentItemRepository.publish(id, extras, ctx)— atomic CAS viarepo.claim(), setspublishedAt, emitscontent.published.ContentItemRepository.unpublish(id, ctx)— back to draft, emitscontent.unpublished.ContentItemRepository.archive(id, ctx)— emitscontent.archived.ContentTypeRepository.upsertByKey(orgId, key, input, ctx)— register or update a content type.AssetRepository.registerUpload(input, ctx)— record a completed upload, emitsasset.created.
All CRUD primitives (getById, getAll, update, delete, claim, cursor) inherit from mongokit's Repository. No re-exports.
Events
import { CMS_EVENTS } from '@classytic/cms';
CMS_EVENTS.CONTENT_CREATED // 'content.created'
CMS_EVENTS.CONTENT_PUBLISHED // 'content.published'
CMS_EVENTS.CONTENT_UNPUBLISHED
CMS_EVENTS.CONTENT_ARCHIVED
CMS_EVENTS.CONTENT_TYPE_UPSERTED
CMS_EVENTS.ASSET_CREATED
CMS_EVENTS.ASSET_DELETEDHosts subscribe via their own EventTransport (passed to createCms). The package ships an InProcessCmsBus fallback for dev.
Multi-tenancy
multiTenant: true injects organizationId (ObjectId) into every schema and adds the multi-tenant plugin. Repositories filter by ctx.organizationId on every operation. Soft-delete and audit-log plugins are NOT included by default — hosts opt in via plugins: [...].
What this package does NOT do
- No HTTP routes (arc owns that).
- No React, no editor UI (host owns that).
- No storage adapter impl (host provides R2/S3/etc. presigner; package only stores Asset metadata).
- No AI / Prism integration (host wires that into its editor).
- No versions / drafts as sibling docs (v0.2; v0.1 uses
statusenum like commerce-cms does today). - No tags / taxonomy (v0.2).
License
MIT
