@classytic/cms-ui
v0.3.1
Published
Composition layer for @classytic/cms: Plate-based block editor, form editor, MDX editor. Layers over the host's shadcn (Base UI) primitives via the @/components/ui/* alias (Fluid pattern). Shared BlockSpec contract (defineBlock), domain blocks, autosave,
Readme
@classytic/cms-ui
Composition layer for @classytic/cms. Plate-based block editor + formkit form editor + MDX editor, dispatched by ContentType.displayMode. Layers over the host's shadcn primitives via the @/components/ui/* alias.
The big idea — three layers, single brand source
Host's components/ui/* ← shadcn primitives (Button, Popover, ...)
↑ host owns, host modifies, host brands
─────────────────────────────────────────────────
@classytic/cms-ui ← composes Plate plugins into <CmsEditor>
+ vendored Plate UI reskinned to use the host alias
+ our custom blocks (AssetImage, ContentLink)
─────────────────────────────────────────────────
Host app ← mounts <CmsEditor> with host's blocks/AI/asset pickerWhen the host modifies a <Button> in components/ui/, every Plate toolbar button, every Fluid card action, every custom block control re-adopts the change instantly. One source of brand truth. No theme drift.
Install
npm install @classytic/cms @classytic/cms-ui @classytic/fluid platejs
# Plate plugin packages (the full peer set this package composes):
npm install @platejs/autoformat @platejs/basic-nodes @platejs/caption \
@platejs/code-block @platejs/combobox @platejs/floating \
@platejs/footnote @platejs/indent @platejs/link @platejs/list \
@platejs/markdown @platejs/media @platejs/selection \
@platejs/slash-command
# drag-to-reorder + slash-menu combobox runtime:
npm install @ariakit/react @atlaskit/pragmatic-drag-and-drop \
@atlaskit/pragmatic-drag-and-drop-hitbox \
@atlaskit/pragmatic-drag-and-drop-live-region
# optional: code-block syntax highlighting, and form-mode content:
npm install lowlight
npm install @classytic/formkitThe exact peer set is the source of truth in this package's
peerDependencies. If a bundler reportsCan't resolve '@platejs/…', install that package — it's a declared peer.
Host requirements — shadcn primitives (Base UI variant)
This package targets the Base UI variant of shadcn/ui — the same one
@classytic/fluid expects (see Fluid's src/types/shadcn.d.ts, which we mirror
verbatim). The host must have a @/components/ui/* path alias and these
primitives installed:
| Primitive | Used by |
|---|---|
| @/components/ui/button | toolbar, code-block language picker |
| @/components/ui/checkbox | to-do list items |
| @/components/ui/command | code-block language picker (cmdk-based in both shadcn variants) |
| @/components/ui/popover | code-block language picker |
| @/components/ui/textarea | MDX-mode editor |
| @/lib/utils (cn) | every vendored component |
Install them with the shadcn CLI you already use for Fluid. If you don't have
the Base UI shadcn set up, run the shadcn-baseui skill (see below) — it
scaffolds the alias + primitives.
Radix shadcn vs Base UI shadcn. Most primitives have an identical import
surface across both variants, and the slash menu is variant-agnostic (it's
built on Plate's own Ariakit InlineCombobox, not on your command). The few
places we touch a host primitive (button/checkbox/command/popover/textarea) are
typed against the Base UI API. If you're on classic Radix shadcn and hit a prop
mismatch, the fix is local to your components/ui/* — the package imports
through your alias, so your copy wins.
Missing a primitive? Imports resolve at your build time, so a missing
@/components/ui/command surfaces as a clear bundler error
(Cannot resolve '@/components/ui/command') pointing at the exact file — not a
silent runtime failure. The table above is the full list to pre-install.
Pre-req recap: host has the @/components/ui/* alias resolving to their shadcn
primitives directory (standard Next.js shadcn setup; also required by
@classytic/fluid).
Next.js / Turbopack integration notes
Two host-side settings are required for this package to bundle correctly under Next.js (App Router / Turbopack):
1. Add @classytic/cms-ui to transpilePackages
This package ships ESM that imports the host's @/components/ui/* alias (the
Fluid pattern). Turbopack does not apply the host's tsconfig path aliases
inside node_modules by default, so the alias won't resolve and the build
fails. Transpile the package through the host's resolution:
// next.config.ts
const nextConfig = {
// Both fluid and cms-ui import `@/components/ui/*`.
transpilePackages: ["@classytic/fluid", "@classytic/cms-ui"],
};2. Consuming a local / unpublished build — use a packed tarball, not file:
A file: dependency that symlinks to a checkout outside the host's project
root (e.g. file:../../cms-ui) breaks Turbopack's subpath-export resolution
— root-level imports work but @classytic/cms-ui/contract, /mdx-io, etc.
fail with Module not found, because Turbopack resolves the symlink's realpath
(outside the workspace) and doesn't apply the exports map there.
Consume a real node_modules copy instead — pack a tarball and install it
(this also mirrors how the published install behaves):
# in the cms-ui checkout
npm pack # -> classytic-cms-ui-<version>.tgz
# in the host app
npm install ../path/to/classytic-cms-ui-<version>.tgzOnce @classytic/cms-ui is published to npm, install it by version range as
normal — these notes only apply to local/unpublished builds.
3. Tailwind v4 — import the styles partial
This package ships no precompiled CSS — its vendored Plate UI (toolbar,
editor container, placeholders, block spacing, focus rings) is styled with
Tailwind utility classes that your build must generate. Import the bundled
styles.css partial in your CSS entry (after @import "tailwindcss"), exactly
like @classytic/fluid:
@import "tailwindcss";
@import "@classytic/fluid/styles.css";
@import "@classytic/cms-ui/styles.css"; /* generates cms-ui's classes */styles.css self-declares @source "./dist/**/*.mjs" relative to the package,
so you never hand-write a fragile ../node_modules/... path. Without it the
editor mounts but renders unstyled (raw, edge-to-edge content). The classes
reference your theme tokens (border-input, muted-foreground, ring, brand
colors), so the editor adopts your theme automatically once they're generated.
Use
import { CmsEditor } from '@classytic/cms-ui';
import { defineBlock } from '@classytic/cms-ui/contract';
import { useAutosave } from '@classytic/cms-ui/hooks';
// Host's domain block — one file, ~30 lines.
const QuizBlock = defineBlock({
key: 'quiz',
label: 'Quiz',
icon: QuizIcon,
category: 'interactive',
schema: z.object({ questionId: z.string(), options: z.array(z.string()) }),
Component: QuizComponent,
toMdx: (n) => `<Quiz questionId="${n.questionId}" />`,
fromMdx: (jsx) => ({ questionId: jsx.attrs.questionId, options: [] }),
});
export function LessonEditor({ lessonId }) {
const { item, contentType, save } = useCmsItem(lessonId); // host's API hook
const autosave = useAutosave({ onSave: save, debounceMs: 1500 });
return (
<CmsEditor
item={item}
contentType={contentType}
blocks={[QuizBlock]} // host owns the enabled-blocks list
onChange={autosave.onChange}
aiAdapter={prismAdapter} // optional — wires AI features
onPickAsset={openAssetLibrary} // optional — host's media picker
/>
);
}What lives in this package
| Module | What |
|---|---|
| <CmsEditor> (root) | Dispatcher — picks block/form/mdx editor by ContentType.displayMode |
| @classytic/cms-ui/block | <CmsBlockEditor> — Plate-based block editor + kit-builder + custom blocks |
| @classytic/cms-ui/form | <CmsFormEditor> — thin @classytic/formkit wrapper |
| @classytic/cms-ui/mdx | <CmsMdxEditor> — Plate in markdown-only mode |
| @classytic/cms-ui/contract | defineBlock(), AiAdapter interface |
| @classytic/cms-ui/hooks | useAutosave() and friends |
| @classytic/cms-ui/mdx-io | Slate AST ↔ MDX string serializers with custom-block round-trip |
Plate's UI components (toolbar, slash menu, drag handle, popovers) are vendored into src/plate-ui/ and reskinned to consume the host's @/components/ui/* alias. The host doesn't run any shadcn install command for them — they ship inside this package.
What the host owns (this package never touches)
- Auth, routing, page chrome, navigation, sidebars
- Brand theme tokens (Fluid handles)
- Asset library viewer / picker UI (we expose
onPickAssetcallback) - Asset storage adapter (R2/S3/Bunny presigned URLs)
- List / table / filter UI for ContentItems
- Tier gating — host computes
blocksarray; notierconcept in this package - Save button placement, autosave indicator UI
- Publish / archive button placement (host wires
cms.repositories.contentItem.publish()to whatever button) - Brand-specific blocks: host defines via
defineBlock(), passes viaextraBlocksprop - AI provider implementation: host implements
AiAdapter, passes viaaiAdapterprop
Block contract
import { defineBlock } from '@classytic/cms-ui/contract';
defineBlock({
key: string; // Plate node type + MDX JSX tag name
label: string; // slash menu label
icon?: ComponentType; // slash menu / toolbar icon
description?: string;
category?: 'text' | 'media' | 'embed' | 'layout' | 'interactive';
schema: ZodSchema; // validates node props at save + render
Component: ComponentType<NodeProps>; // rendered inside Plate
toMdx?: (node) => string; // MDX serializer (defaults to JSX-style)
fromMdx?: (jsx) => Node; // MDX deserializer
plugin?: PlatePlugin; // optional Plate plugin overrides
})Two files per domain block: the defineBlock() call (registered into the editor) and the Component (renders the block visually). The same Component works in editor mode and in the consumer-site renderer — host imports both.
AI integration
Host implements AiAdapter:
import type { AiAdapter } from '@classytic/cms-ui/contract';
export const prismAdapter: AiAdapter = {
rewrite: async (selection, { tone }) => { /* call Prism */ },
expand: async (selection) => { /* call Prism */ },
generate: async (prompt) => { /* call Prism */ },
};Pass via <CmsEditor aiAdapter={prismAdapter}> — when present, the AI kit is loaded. When absent, AI features are silently disabled. No tier logic in this package.
Build
npm run build # tsdown — ESM, unbundle, .d.mts + .mjs per source file
npm run typecheck # tsc --noEmit (strict)
npm test # unit + integration (happy-dom)License
MIT
