@josiesam/lms-cms
v0.1.0
Published
A headless, multi-tenant content block system for building Learning Management Systems (LMS) in React. Provides a schema-driven block editor, renderer, and adapter layer that works with any backend.
Downloads
82
Readme
@lms-cms
A headless, multi-tenant content block system for building Learning Management Systems (LMS) in React. Provides a schema-driven block editor, renderer, and adapter layer that works with any backend.
Packages
| Package | Description |
|---------|-------------|
| @lms-cms/core | Shared types, plugin registry, migrations, utilities |
| @lms-cms/adapters | REST, GraphQL, and mock data adapters |
| @lms-cms/renderer | React renderer, theme provider, data hooks |
| @lms-cms/editor | Visual block editor UI |
| @lms-cms/blocks | All built-in content blocks |
Quick Start
npm install @lms-cms/renderer @lms-cms/adapters @lms-cms/blocksimport { LMSProvider, ContentRenderer, useContent } from '@lms-cms/renderer'
import { createRestAdapter } from '@lms-cms/adapters'
import { registerAllBlocks } from '@lms-cms/blocks'
registerAllBlocks()
const adapter = createRestAdapter({ baseUrl: 'https://api.yourapp.com' })
function App() {
return (
<LMSProvider adapter={adapter} tenantId="acme-corp">
<CourseViewer contentId="lesson-123" />
</LMSProvider>
)
}
function CourseViewer({ contentId }: { contentId: string }) {
const { data: doc, isLoading } = useContent(contentId)
if (isLoading || !doc) return <div>Loading...</div>
return <ContentRenderer doc={doc} />
}Architecture
The system is organized into three independent planes:
┌─────────────────────────────────────────────────┐
│ EDITING PLANE │
│ LMSEditor · BlockToolbar · BlockCanvas │
└─────────────────┬───────────────────────────────┘
│ ContentDoc JSON
┌────────▼────────┐
│ PLUGIN REGISTRY │ ← Zustand store (shared singleton)
│ Map<type, Def> │
└────────┬────────┘
│
┌─────────────────▼───────────────────────────────┐
│ RENDERING PLANE │
│ ContentRenderer · ThemeProvider · LMSProvider │
└─────────────────┬───────────────────────────────┘
│ useContent() hook
┌─────────────────▼───────────────────────────────┐
│ DATA PLANE │
│ IContentAdapter · REST · GraphQL · Mock │
└─────────────────────────────────────────────────┘No plane imports directly from another — all communication is through the plugin registry and typed interfaces.
@lms-cms/core
Foundation package. Zero React dependencies. Every other package depends on it.
Installation
npm install @lms-cms/coreContentDoc — Universal Format
Every piece of content is a ContentDoc:
interface ContentDoc {
id: string // UUID
version: number // schema version for migrations
meta: Record<string, unknown>
blocks: ContentBlock[]
createdAt?: string
updatedAt?: string
}
interface ContentBlock {
id: string // UUID, stable across edits
type: string // e.g. "core/text", "lms/quiz"
version: number // block schema version
data: Record<string, unknown>
children?: ContentBlock[] // nested blocks (columns, groups)
}Plugin Registry
import { registerBlock, getBlock, listBlocks } from '@lms-cms/core'
// Register a block definition (called once at app init)
registerBlock(myBlockDef)
// Retrieve a definition by type
const def = getBlock('core/text')
// List all blocks, optionally by category
const textBlocks = listBlocks('text')
const allBlocks = listBlocks()IContentAdapter Interface
All adapters implement this contract:
interface IContentAdapter {
fetchContent(id: string, options?: FetchOptions): Promise<ContentDoc>
saveContent(doc: ContentDoc, options?: SaveOptions): Promise<ContentDoc>
listContents(query: ContentQuery): Promise<ContentList>
deleteContent(id: string): Promise<void>
uploadAsset(file: File, options?: AssetOptions): Promise<AssetResult>
}Utilities
import { createBlock, createDoc, mergeTheme, themeToCSS } from '@lms-cms/core'
const block = createBlock('core/text', { text: 'Hello', align: 'left' })
const doc = createDoc([block], { courseId: 'abc' })
const theme = mergeTheme({ '--color-primary': '#0EA5E9' })Schema Validation
import { ContentDocSchema, ContentBlockSchema } from '@lms-cms/core'
const result = ContentDocSchema.safeParse(rawJson)
if (!result.success) console.error(result.error)@lms-cms/adapters
Installation
npm install @lms-cms/adaptersREST Adapter
import { createRestAdapter } from '@lms-cms/adapters'
const adapter = createRestAdapter({
baseUrl: 'https://api.yourapp.com',
auth: { type: 'bearer', token: 'your-token' },
timeout: 10000,
})Auth options: bearer, apikey, basic.
GraphQL Adapter
import { createGraphQLAdapter } from '@lms-cms/adapters'
const adapter = createGraphQLAdapter({
endpoint: 'https://api.yourapp.com/graphql',
auth: { type: 'bearer', token: 'your-token' },
})Mock Adapter (development & testing)
import { createMockAdapter, createSeedMockAdapter } from '@lms-cms/adapters'
// Empty in-memory store
const adapter = createMockAdapter({ delay: 300 })
// Pre-seeded with sample LMS content (heading, text, image, quiz)
const adapter = createSeedMockAdapter()Custom Adapter
Implement IContentAdapter from @lms-cms/core:
import type { IContentAdapter, ContentDoc } from '@lms-cms/core'
export function createSupabaseAdapter(client: SupabaseClient): IContentAdapter {
return {
async fetchContent(id) {
const { data } = await client.from('content').select('*').eq('id', id).single()
return data as ContentDoc
},
async saveContent(doc) {
const { data } = await client.from('content').upsert(doc).select().single()
return data as ContentDoc
},
async listContents(query) { /* ... */ },
async deleteContent(id) { /* ... */ },
async uploadAsset(file) { /* ... */ },
}
}@lms-cms/renderer
Installation
npm install @lms-cms/renderer @lms-cms/coreLMSProvider
Root context — wrap your entire app (or a section of it):
import { LMSProvider } from '@lms-cms/renderer'
<LMSProvider
adapter={adapter} // required: IContentAdapter
tenantId="acme-corp" // required: used for theme scoping and cache isolation
theme={acmeTheme} // optional: Partial<LMSTheme> brand overrides
plugins={[customBlock]} // optional: additional block definitions
>
{children}
</LMSProvider>Multiple LMSProvider instances can coexist on the same page with different themes — useful for multi-tenant admin UIs.
ContentRenderer
Renders a ContentDoc to React components:
import { ContentRenderer } from '@lms-cms/renderer'
<ContentRenderer
doc={doc}
className="my-content"
onBlockClick={(block) => console.log(block.type)}
/>Unknown block types render a visible error in development and nothing in production.
Data Hooks
All hooks must be used inside <LMSProvider>.
import { useContent, useContentList, useSaveContent, useDeleteContent } from '@lms-cms/renderer'
// Fetch a single document (runs block migrations automatically)
const { data: doc, isLoading, error } = useContent('lesson-123')
// Fetch a list with optional filters
const { data: list } = useContentList({ limit: 20, search: 'react' })
// Save (create or update)
const { mutate: save } = useSaveContent()
save(doc)
// Delete
const { mutate: remove } = useDeleteContent()
remove('lesson-123')Cache is scoped by tenantId — switching tenants never cross-contaminates data.
Theming
import { useTheme } from '@lms-cms/renderer'
function MyComponent() {
const theme = useTheme()
return <div style={{ color: theme['--color-primary'] }}>Hello</div>
}All CSS custom properties are injected on a scoped container element — not on :root. This means tenant themes are isolated even when multiple providers coexist.
Available tokens
| Token | Default | Usage |
|-------|---------|-------|
| --color-primary | #6C2BD9 | Buttons, active states |
| --color-primary-hover | #5B22B8 | Hover states |
| --color-secondary | #E9D5FF | Selected states, light backgrounds |
| --color-surface | #FFFFFF | Card backgrounds |
| --color-surface-raised | #F9FAFB | Nested card backgrounds |
| --color-background | #F3F4F6 | Page background |
| --color-text-primary | #111827 | Body text |
| --color-text-secondary | #6B7280 | Labels, captions |
| --color-text-muted | #9CA3AF | Placeholders |
| --color-border | #E5E7EB | All borders |
| --color-danger | #EF4444 | Error states |
| --color-success | #22C55E | Success states |
| --color-warning | #F59E0B | Warning states |
| --font-body | "Inter", system-ui, sans-serif | Body text |
| --font-heading | "Inter", system-ui, sans-serif | Headings |
| --font-mono | "JetBrains Mono", monospace | Code |
| --font-size-base | 16px | Base font size |
| --border-radius-sm | 4px | Inputs, small elements |
| --border-radius-md | 8px | Cards, buttons |
| --border-radius-lg | 12px | Modals, large cards |
| --shadow-sm | 0 1px 2px rgba(0,0,0,0.05) | Subtle elevation |
| --shadow-md | 0 4px 6px -1px rgba(0,0,0,0.1) | Cards, dropdowns |
@lms-cms/editor
Installation
npm install @lms-cms/editor @lms-cms/coreLMSEditor
Visual block editor. Renders a two-panel UI: block picker on the left, editable canvas on the right.
import { LMSEditor } from '@lms-cms/editor'
import { createDoc } from '@lms-cms/core'
import { useState } from 'react'
function CourseEditor() {
const [doc, setDoc] = useState(() => createDoc())
return (
<LMSEditor
doc={doc}
onChange={setDoc}
readOnly={false}
/>
)
}| Prop | Type | Default | Description |
|------|------|---------|-------------|
| doc | ContentDoc | required | The document to edit |
| onChange | (doc: ContentDoc) => void | required | Called on every change |
| readOnly | boolean | false | Hides the block picker, disables controls |
The editor reads all registered blocks from the plugin registry. Register blocks before mounting the editor.
@lms-cms/blocks
Installation
npm install @lms-cms/blocks @lms-cms/coreBlock Catalogue
Text blocks (category: text)
| Block type | Label | Description |
|-----------|-------|-------------|
| core/text | Paragraph | Rich paragraph with text alignment |
| core/heading | Heading | H1–H6 with level picker |
Media blocks (category: media)
| Block type | Label | Formats supported |
|-----------|-------|-------------------|
| core/image | Image | Any image URL (JPEG, PNG, GIF, WebP, SVG) |
| core/video | Video | YouTube, Vimeo (iframe), direct .mp4 / .webm |
| core/audio | Audio | .mp3, .ogg, .wav, .m4a and any audio URL |
| core/pdf | PDF | Any PDF URL — rendered via browser iframe viewer |
| core/file | File Download | PDF, DOCX, XLSX, PPTX, ZIP, CSV, MP3, MP4, and any other file type |
| core/embed | Embed | iFrame embed — Loom, Notion, Figma, Google Slides, etc. |
LMS blocks (category: lms)
| Block type | Label | Description |
|-----------|-------|-------------|
| lms/quiz | Quiz | Multiple-choice questions, scoring, pass/fail, retry |
| lms/progress | Progress Bar | Visual progress indicator with configurable color |
| lms/checklist | Checklist | Interactive completion checklist with required items |
Registering blocks
import { registerAllBlocks } from '@lms-cms/blocks'
// Call once before rendering — registers all 11 built-in blocks
registerAllBlocks()Or cherry-pick individual blocks:
import { registerBlock } from '@lms-cms/core'
import { quizBlockDef, videoBlockDef, pdfBlockDef } from '@lms-cms/blocks'
registerBlock(quizBlockDef)
registerBlock(videoBlockDef)
registerBlock(pdfBlockDef)Or pass them through LMSProvider:
import { videoBlockDef, pdfBlockDef } from '@lms-cms/blocks'
<LMSProvider plugins={[videoBlockDef, pdfBlockDef]} ...>Block schemas (data shapes)
core/text
{ text: string; align: 'left' | 'center' | 'right' }core/heading
{ text: string; level: 1 | 2 | 3 | 4 | 5 | 6 }core/image
{ url: string; alt: string; caption?: string; width: 'full' | 'wide' | 'medium' | 'small' }core/video
{
url: string // YouTube, Vimeo, or direct video URL
title?: string
caption?: string
autoplay: boolean
loop: boolean
controls: boolean
}core/audio
{
url: string // any audio URL
title?: string
caption?: string
controls: boolean
loop: boolean
}core/pdf
{
url: string
title?: string
height: 'small' | 'medium' | 'large' | 'full'
showDownload: boolean
}core/file
{
url: string
filename: string
fileType: 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'zip' | 'csv' | 'mp3' | 'mp4' | 'other'
size?: string // e.g. "2.4 MB"
description?: string
}core/embed
{
url: string // any embeddable URL
title?: string
height: number // pixels, 100–2000
caption?: string
}lms/quiz
{
questions: Array<{
id: string
text: string
options: string[] // 2–6 options
correctIndex: number
explanation?: string
}>
passingScore: number // 0–100
showExplanations: boolean
}lms/progress
{
label: string
value: number // 0–100
showPercentage: boolean
color: 'primary' | 'success' | 'warning' | 'danger'
}lms/checklist
{
title?: string
items: Array<{ id: string; text: string; required: boolean }>
showCompletion: boolean
}Building Custom Blocks
Any block type can be added without modifying the library. Custom blocks are namespaced to avoid collisions.
import { z } from 'zod'
import type { BlockDefinition } from '@lms-cms/core'
import { registerBlock } from '@lms-cms/core'
const calloutSchema = z.object({
text: z.string().min(1),
type: z.enum(['info', 'warning', 'success', 'danger']),
title: z.string().optional(),
})
type CalloutData = z.infer<typeof calloutSchema>
const calloutBlockDef: BlockDefinition<CalloutData> = {
type: 'acme/callout', // namespace with your org prefix
version: 1,
label: 'Callout',
icon: '💬',
category: 'layout',
schema: calloutSchema,
defaultData: { text: '', type: 'info' },
EditorComponent: CalloutEditor,
RenderComponent: CalloutRenderer,
}
registerBlock(calloutBlockDef)Block Naming Convention
core/text ← built-in library blocks
lms/quiz ← built-in LMS blocks
acme/callout ← your org's custom blocks
eduflow/poll ← another tenant's blocksBlock Versioning and Migrations
When a block's data shape changes, bump version and add a migration:
const richTextDef: BlockDefinition = {
type: 'acme/richtext',
version: 2,
schema: z.object({ text: z.object({ value: z.string(), markdown: z.boolean() }) }),
defaultData: { text: { value: '', markdown: false } },
migrations: [
{
fromVersion: 1,
toVersion: 2,
migrate: (data) => ({
text: { value: String(data['text'] ?? ''), markdown: false },
}),
},
],
// ...
}Migrations run automatically via migrateDoc() when content is fetched — no database changes needed.
Multi-Tenant Usage
// Each tenant gets its own LMSProvider with isolated theme + cache
function App({ tenantId, theme, adapter }: TenantConfig) {
return (
<LMSProvider adapter={adapter} theme={theme} tenantId={tenantId}>
<CourseViewer />
</LMSProvider>
)
}Theme switching is instant — no page reload, no flash:
const [tenant, setTenant] = useState(TENANTS[0])
// Re-renders with new CSS custom properties applied to the scoped container
<LMSProvider theme={tenant.theme} tenantId={tenant.id} ...>Design Principles
- Dependency Inversion — components depend on interfaces, not implementations
- Progressive Adoption — install just
@lms-cms/rendererto start; add editor and blocks incrementally - Schema-as-Code — Zod validates all block data; no database migrations for content schema changes
- Zero Hardcoded Brand Values — every visual value references a CSS custom property
- Block Isolation — each block is a self-contained unit; blocks cannot read each other's data
Development
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Start demo app
pnpm --filter demo dev
# Watch mode for a specific package
pnpm --filter @lms-cms/renderer devLocal testing with yalc
# Publish to local yalc store
pnpm build
yalc publish packages/core
yalc publish packages/adapters
yalc publish packages/renderer
yalc publish packages/editor
yalc publish packages/blocks
# In your consuming project
yalc add @lms-cms/renderer
yalc add @lms-cms/blocks
# Push updates after changes
pnpm build && yalc push packages/rendererLicense
MIT
