@classytic/cms-ui
v0.5.0
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.
Composable block packs
Use defineBlock with separate load and loadEditor modules, group manifests
with defineBlockPack, and create one createBlockRegistry. The registry supplies
the editor blocks, reader tag map, serializer specs, catalog and validation.
Unselected lazy atomic blocks stay as cards; selecting them loads authoring UI.
See the registry guide and the
third-party slide-deck example.
checkBlock is exported from /testing; compileContent from /server pairs
with ContentView from /viewer for precompiled content.
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 platejs
# Plate plugin packages (the full peer set this package composes):
npm install @platejs/basic-nodes @platejs/code-block @platejs/combobox \
@platejs/dnd \
@platejs/footnote @platejs/indent @platejs/link @platejs/list \
@platejs/markdown @platejs/selection \
@platejs/slash-command
# drag-to-reorder, slash-menu combobox, code-block highlighting:
npm install @ariakit/react react-dnd react-dnd-html5-backend lowlight
# optional: MDX rendering in the viewer
npm install next-mdx-remote-clientForm-mode content (displayMode: 'form') is rendered by the host's own form
system through <CmsFormEditor render={…}> (e.g. @classytic/formkit); this
package does not depend on one.
The 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',
tag: 'Quiz',
void: true,
label: 'Quiz',
icon: QuizIcon,
category: 'interactive',
schema: z.object({ questionId: z.string(), options: z.array(z.string()) }),
Component: QuizComponent,
toAttrs: (node) => ({ questionId: node.questionId }),
fromAttrs: (attrs) => ({ questionId: String(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}
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() and the shared block contract |
| @classytic/cms-ui/hooks | useAutosave() and friends |
| @classytic/cms-ui/mdx-io | Projects UI blocks into CMS serializer BlockSpec values |
| @classytic/cms-ui/viewer | Lightweight client MDX renderer; optional runtime, no Plate/editor imports |
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.
Import the consumer viewer from its subpath (and dynamically in Next when appropriate):
const Viewer = dynamic(
() => import('@classytic/cms-ui/viewer').then((module) => module.CmsMdxViewer),
{ ssr: false },
);The /viewer graph depends on next-mdx-remote-client but imports no Plate kits.
Hosts supply a closed components registry for domain blocks. Fluid remains the
owner of prose/layout presentation around the viewer.
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 via theblocksprop
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';
tag?: string; // MDX JSX tag; defaults from key
void?: boolean; // explicit atomic/container behavior
schema: ZodSchema; // validates node props at save + render
Component: ComponentType<NodeProps>; // rendered inside Plate
toAttrs?: (node) => Record<string, unknown>;
fromAttrs?: (attrs) => Record<string, unknown>;
})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.
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
Trademark
MIT-licensed code. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see TRADEMARK.md.
