@bernierllc/content-management-suite
v2.2.1
Published
Comprehensive content management suite with editorial workflows, content types, and admin UI
Readme
/* Copyright (c) 2025 Bernier LLC
This file is licensed to the client under a limited-use license. The client may use and modify this code only within the scope of the project it was delivered for. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC. */
Content Management Suite
A comprehensive content management suite with editorial workflows, content types, and admin UI built with TypeScript and Tamagui.
Features
- 🎯 Editorial Workflows: Configurable content workflows with stages, transitions, and permissions
- 📝 Content Types: Pluggable content type system supporting text, images, audio, and video
- ⚡ Auto-save: Intelligent auto-save with backoff retry and debounce
- 🗑️ Soft Delete: Configurable soft delete with user visibility options
- 🎨 Modern UI: Tamagui-based components with responsive design
- 🔧 Configuration: File + database override + environment variable configuration
- 🔌 Plugin System: Extensible plugin architecture for custom functionality
- 🛡️ Security: JWT authentication, role-based permissions, and rate limiting
- 📊 Analytics: Built-in analytics and metrics collection
- 🌐 API: RESTful API with comprehensive endpoints
- 📱 Admin UI: NeverAdmin integration for workflow configuration
- 🔍 Search: Full-text search across all content types
- 📈 Performance: Caching, compression, and optimization
Installation
npm install @bernierllc/content-management-suiteQuick Start
import { createContentManagementSuite } from '@bernierllc/content-management-suite';
const suite = createContentManagementSuite({
config: {
server: {
port: 3000,
host: 'localhost'
}
}
});
await suite.start();
console.log('Content Management Suite started on http://localhost:3000');Configuration
The suite supports comprehensive configuration through multiple sources:
1. File Configuration
Create a content-management.config.json file:
{
"server": {
"port": 3000,
"host": "localhost",
"cors": {
"origin": "*",
"credentials": true
}
},
"database": {
"type": "sqlite",
"name": "content_management"
},
"content": {
"defaultWorkflow": {
"id": "standard",
"name": "Standard Workflow",
"stages": [
{
"id": "write",
"name": "Write",
"order": 1,
"isPublishStage": false,
"allowsScheduling": false,
"permissions": ["content.edit"]
},
{
"id": "publish",
"name": "Publish",
"order": 2,
"isPublishStage": true,
"allowsScheduling": true,
"permissions": ["content.publish"]
}
]
}
}
}2. Environment Variables
# Server
CONTENT_MANAGEMENT_PORT=3000
CONTENT_MANAGEMENT_HOST=localhost
# Database
CONTENT_MANAGEMENT_DATABASE_TYPE=postgresql
CONTENT_MANAGEMENT_DATABASE_URL=postgresql://user:password@localhost:5432/content_management
# Security
CONTENT_MANAGEMENT_JWT_SECRET=your-secret-key
CONTENT_MANAGEMENT_JWT_EXPIRES_IN=24h
# Integrations
CONTENT_MANAGEMENT_NEVERADMIN_ENABLED=true
CONTENT_MANAGEMENT_NEVERADMIN_URL=https://admin.myapp.com
CONTENT_MANAGEMENT_NEVERADMIN_API_KEY=your-api-key3. Database Override
Configuration can be overridden through the database for runtime changes:
await suite.updateConfig({
ui: {
theme: {
mode: 'dark'
}
}
});Content Types
The suite comes with four built-in content types:
Text Content
import { TextContentType } from '@bernierllc/content-management-suite';
// Register text content type
suite.registerContentType(TextContentType);
// Create text content
const textContent = {
title: 'My Blog Post',
slug: 'my-blog-post',
body: '<p>Hello world!</p>',
excerpt: 'A brief description',
tags: ['blog', 'example'],
categories: ['general'],
author: {
id: 'user-1',
name: 'John Doe',
email: '[email protected]'
}
};Image Content
import { ImageContentType } from '@bernierllc/content-management-suite';
// Register image content type
suite.registerContentType(ImageContentType);
// Create image content
const imageContent = {
title: 'Beautiful Landscape',
slug: 'beautiful-landscape',
description: 'A stunning mountain landscape',
altText: 'Mountain landscape with sunset',
imageUrl: 'https://example.com/images/landscape.jpg',
tags: ['landscape', 'nature'],
categories: ['photography'],
author: {
id: 'user-1',
name: 'John Doe',
email: '[email protected]'
}
};Audio Content
import { AudioContentType } from '@bernierllc/content-management-suite';
// Register audio content type
suite.registerContentType(AudioContentType);
// Create audio content
const audioContent = {
title: 'My Podcast Episode',
slug: 'my-podcast-episode',
description: 'A great podcast episode',
audioUrl: 'https://example.com/audio/episode.mp3',
duration: 1800, // 30 minutes
metadata: {
artist: 'John Doe',
album: 'Tech Talk Podcast',
year: 2023
},
tags: ['podcast', 'technology'],
categories: ['podcast'],
author: {
id: 'user-1',
name: 'John Doe',
email: '[email protected]'
}
};Video Content
import { VideoContentType } from '@bernierllc/content-management-suite';
// Register video content type
suite.registerContentType(VideoContentType);
// Create video content
const videoContent = {
title: 'My Video Tutorial',
slug: 'my-video-tutorial',
description: 'A comprehensive tutorial',
videoUrl: 'https://example.com/videos/tutorial.mp4',
duration: 1800, // 30 minutes
dimensions: {
width: 1920,
height: 1080
},
metadata: {
director: 'John Doe',
genre: 'Educational',
year: 2023
},
tags: ['tutorial', 'video'],
categories: ['tutorial'],
author: {
id: 'user-1',
name: 'John Doe',
email: '[email protected]'
}
};Editorial Workflows
Configure custom editorial workflows:
const customWorkflow = {
id: 'editorial',
name: 'Editorial Workflow',
description: 'Multi-stage editorial workflow',
stages: [
{
id: 'write',
name: 'Write',
order: 1,
isPublishStage: false,
allowsScheduling: false,
permissions: ['content.edit']
},
{
id: 'edit',
name: 'Edit',
order: 2,
isPublishStage: false,
allowsScheduling: false,
permissions: ['content.edit']
},
{
id: 'review',
name: 'Review',
order: 3,
isPublishStage: false,
allowsScheduling: false,
permissions: ['content.review']
},
{
id: 'publish',
name: 'Publish',
order: 4,
isPublishStage: true,
allowsScheduling: true,
permissions: ['content.publish']
}
],
transitions: [
{
id: 'write-to-edit',
from: 'write',
to: 'edit',
permissions: ['content.edit']
},
{
id: 'edit-to-review',
from: 'edit',
to: 'review',
permissions: ['content.review']
},
{
id: 'review-to-publish',
from: 'review',
to: 'publish',
permissions: ['content.publish']
}
]
};
await suite.workflowService.createWorkflow(customWorkflow);API Endpoints
Content Management
GET /api/content- List contentGET /api/content/:id- Get content by IDPOST /api/content- Create contentPUT /api/content/:id- Update contentDELETE /api/content/:id- Delete contentPOST /api/content/:id/publish- Publish contentPOST /api/content/:id/schedule- Schedule contentPOST /api/content/:id/unpublish- Unpublish content
Workflow Management
GET /api/workflows- List workflowsGET /api/workflows/:id- Get workflow by IDPOST /api/workflows- Create workflowPUT /api/workflows/:id- Update workflowDELETE /api/workflows/:id- Delete workflow
Content Type Management
GET /api/content-types- List content typesGET /api/content-types/:id- Get content type by IDPOST /api/content-types- Create content typePUT /api/content-types/:id- Update content typeDELETE /api/content-types/:id- Delete content type
Configuration Management
GET /api/config- Get configurationPUT /api/config- Update configuration
User Management
GET /api/users- List usersGET /api/users/:id- Get user by IDPOST /api/users- Create userPUT /api/users/:id- Update userDELETE /api/users/:id- Delete user
UI Components
Content Editor
import { ContentEditor } from '@bernierllc/content-management-suite';
<ContentEditor
content={content}
onChange={(content) => console.log('Content changed:', content)}
onSave={(content) => console.log('Save:', content)}
onPublish={(content) => console.log('Publish:', content)}
onSchedule={(content, date) => console.log('Schedule:', content, date)}
showToolbar={true}
showStatusBar={true}
showWordCount={true}
showCharacterCount={true}
placeholder="Start writing your content..."
maxCharacters={10000}
targetWordCount={1000}
/>Workflow Components
import {
WorkflowStepper,
StageActionButtons,
WorkflowTimeline,
WorkflowAdminConfig
} from '@bernierllc/content-management-suite';
// Workflow stepper
<WorkflowStepper
workflow={workflow}
currentStage={currentStage}
onStageChange={(stage) => console.log('Stage changed:', stage)}
/>
// Stage action buttons
<StageActionButtons
stage={currentStage}
content={content}
onAction={(action) => console.log('Action:', action)}
/>
// Workflow timeline
<WorkflowTimeline
workflow={workflow}
content={content}
onStageClick={(stage) => console.log('Stage clicked:', stage)}
/>
// Workflow admin configuration
<WorkflowAdminConfig
workflow={workflow}
onChange={(workflow) => console.log('Workflow changed:', workflow)}
onSave={(workflow) => console.log('Workflow saved:', workflow)}
/>Content List
import { ContentList } from '@bernierllc/content-management-suite';
<ContentList
content={contentList}
onContentSelect={(content) => console.log('Content selected:', content)}
onContentEdit={(content) => console.log('Edit content:', content)}
onContentDelete={(content) => console.log('Delete content:', content)}
onContentPublish={(content) => console.log('Publish content:', content)}
view="table" // table, list, grid, kanban
showSearch={true}
showFilters={true}
showSorting={true}
showPagination={true}
pageSize={20}
/>Plugin System
Create custom plugins:
const analyticsPlugin = {
name: 'analytics',
version: '1.0.0',
description: 'Analytics plugin for content management',
dependencies: ['@bernierllc/analytics'],
install: async (suite) => {
console.log('Installing analytics plugin');
// Initialize analytics
},
uninstall: async (suite) => {
console.log('Uninstalling analytics plugin');
// Cleanup analytics
},
enable: async (suite) => {
console.log('Enabling analytics plugin');
// Enable analytics tracking
},
disable: async (suite) => {
console.log('Disabling analytics plugin');
// Disable analytics tracking
}
};
suite.registerPlugin(analyticsPlugin);Middleware System
Add custom middleware:
suite.addMiddleware({
name: 'request-logger',
handler: (req, res, next) => {
console.log(`${req.method} ${req.path} - ${new Date().toISOString()}`);
next();
},
order: 1
});Hook System
Add custom hooks:
suite.addHook({
name: 'content:created',
handler: async (content) => {
console.log('New content created:', content.id);
// Send notification, update analytics, etc.
},
priority: 1
});Security
JWT Authentication
const suite = createContentManagementSuite({
config: {
security: {
jwt: {
secret: 'your-super-secret-jwt-key',
expiresIn: '24h',
issuer: 'content-management-suite'
}
}
}
});Role-Based Permissions
const suite = createContentManagementSuite({
config: {
security: {
permissions: {
enabled: true,
defaultRole: 'user',
roles: [
{
name: 'admin',
permissions: ['*'],
description: 'Full administrative access'
},
{
name: 'editor',
permissions: ['content.edit', 'content.publish', 'content.schedule'],
description: 'Content editing and publishing'
},
{
name: 'author',
permissions: ['content.edit'],
description: 'Content creation and editing'
},
{
name: 'user',
permissions: ['content.view'],
description: 'Content viewing only'
}
]
}
}
}
});Performance
Caching
const suite = createContentManagementSuite({
config: {
performance: {
cache: {
enabled: true,
ttl: 300, // 5 minutes
maxSize: 1000
}
}
}
});Compression
const suite = createContentManagementSuite({
config: {
performance: {
compression: {
enabled: true,
level: 6
}
}
}
});Rate Limiting
const suite = createContentManagementSuite({
config: {
performance: {
rateLimit: {
enabled: true,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100
}
}
}
});Logging
const suite = createContentManagementSuite({
config: {
logging: {
level: 'info',
format: 'json',
file: {
enabled: true,
path: './logs',
maxSize: '10MB',
maxFiles: 5
},
console: {
enabled: true,
colorize: true
}
}
}
});Integrations
NeverAdmin Integration
const suite = createContentManagementSuite({
config: {
integrations: {
neverAdmin: {
enabled: true,
url: 'https://admin.myapp.com',
apiKey: 'your-api-key',
syncInterval: 300000 // 5 minutes
}
}
}
});NeverHub Integration
const suite = createContentManagementSuite({
config: {
integrations: {
neverHub: {
enabled: true,
url: 'https://hub.myapp.com',
apiKey: 'your-api-key',
packageDiscovery: true
}
}
}
});Error Handling
suite.addHook({
name: 'error',
handler: async (error) => {
console.error('Suite error:', error);
// Send to error tracking service
}
});Graceful Shutdown
process.on('SIGINT', async () => {
console.log('Received SIGINT, shutting down gracefully...');
try {
await suite.stop();
console.log('Suite stopped successfully');
process.exit(0);
} catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
});Development vs Production
const isDevelopment = process.env.NODE_ENV === 'development';
const suite = createContentManagementSuite({
config: {
server: {
port: isDevelopment ? 3000 : 80,
host: isDevelopment ? 'localhost' : '0.0.0.0'
},
logging: {
level: isDevelopment ? 'debug' : 'info',
format: isDevelopment ? 'text' : 'json'
},
security: {
jwt: {
secret: isDevelopment ? 'dev-secret' : process.env.JWT_SECRET
}
}
}
});Testing
# Run unit tests
npm test
# Run Playwright tests
npm run test:playwright
# Run all tests
npm run test:allExamples
See the examples.ts file for comprehensive usage examples:
- Basic setup
- Advanced configuration
- Custom content types
- Plugin system
- Error handling
- API usage
- Graceful shutdown
- Environment-specific setup
AI Namespace (suite.ai)
The suite.ai namespace provides AI-powered content operations. All methods work
through thin service interfaces you supply — the suite does not hard-code a
specific AI provider.
Configuration
import { createContentManagementSuite } from '@bernierllc/content-management-suite';
import type {
AiTagService, AiExcerptService, AiSeoService,
AiPredictService, AiAltTextService,
} from '@bernierllc/content-management-suite';
const suite = createContentManagementSuite({
ai: {
tagService: myTagService, // implements AiTagService
excerptService: myExcerptService, // implements AiExcerptService
seoService: mySeoService, // implements AiSeoService
predictService: myPredictService, // implements AiPredictService
altTextService: myAltTextService, // implements AiAltTextService
},
});suite.ai.tag(contentId, options?)
Suggest tags and categories for a content item.
const result = await suite.ai.tag('content-123', {
maxTags: 10, // default 10
maxCategories: 3, // default 3
existingTags: ['typescript', 'cms'],
existingCategories: ['Engineering'],
});
// result: AiTagResult
// {
// contentId: 'content-123',
// tags: [{ name: string; confidence: number; reason: string }],
// categories: [{ name: string; confidence: number; reason: string }],
// generatedAt: '2026-05-28T00:00:00.000Z'
// }- Confidence scores are 0.0–1.0.
- When
existingTagsare provided, the service should prefer matching those before suggesting new ones. - Persists with
type: 'tagging'. - Emits
ai:taggedevent with the fullAiTagResult. - Throws
NotFoundErrorifcontentIddoes not exist.
suite.ai.enhance(contentId, options?) — excerpt generation extension
When generateExcerpt: true, returns AiExcerptResult instead of EnhancedContent.
// Standard enhancement (unchanged behaviour)
const enhanced = await suite.ai.enhance('content-123', { tone: 'professional' });
// Excerpt generation
const excerpts = await suite.ai.enhance('content-123', {
generateExcerpt: true,
platforms: ['social', 'newsletter', 'seo', 'email-subject'],
});
// excerpts: AiExcerptResult
// {
// contentId: 'content-123',
// excerpts: [{
// platform: 'social' | 'newsletter' | 'seo' | 'email-subject',
// text: string,
// characterCount: number,
// constraints: { maxLength: number; style: string }
// }],
// generatedAt: '...'
// }Platform character limits: social ≤280, newsletter ≤500, seo ≤160, email-subject ≤60.
When no AiExcerptService is configured the suite falls back to truncating the content
body to fit each platform's limit.
- Persists with
type: 'excerpt'. - Emits
ai:excerpts:generatedevent. - Throws
NotFoundErrorifcontentIddoes not exist.
suite.ai.suggest(contentId, options?) — SEO analysis extension
When seo: true, returns AiSeoResult instead of Suggestion[].
// Standard suggestions (unchanged behaviour)
const suggestions = await suite.ai.suggest('content-123');
// SEO analysis
const seo = await suite.ai.suggest('content-123', {
seo: true,
siteUrl: 'https://example.com',
internalPages: [{ url: '/blog', title: 'Blog' }],
});
// seo: AiSeoResult
// {
// contentId: string,
// overallScore: number, // 0-100
// keywords: {
// primaryKeyword: string,
// secondaryKeywords: string[],
// density: number,
// recommendations: string[]
// },
// metaDescription: {
// current: string | null,
// suggested: string, // ≤160 chars
// lengthAssessment: string,
// includesKeyword: boolean
// },
// headingStructure: { structure, issues, suggestions },
// internalLinking: {
// existingLinks: string[],
// suggestedLinks: { url, anchorText }[] // [] when internalPages not provided
// },
// readability: { score, gradeLevel, averageSentenceLength, recommendations },
// generatedAt: string
// }- Requires
seoServiceinaiconfig whenseo: true. - Persists with
type: 'seo'. - Emits
ai:seo:analyzedevent. - Throws
NotFoundErrorifcontentIddoes not exist.
suite.ai.predict(contentId, options?)
Predict content performance before publishing.
const prediction = await suite.ai.predict('content-123', {
audience: 'software developers',
category: 'Engineering',
timezone: 'America/Denver', // default: 'America/Denver'
platforms: ['website', 'social', 'newsletter'],
});
// prediction: AiPredictionResult
// {
// contentId: string,
// engagementScore: number, // 0-100
// audienceFitScore: number, // 0-100
// qualityScore: number, // 0-100
// publishTimeSuggestions: [{
// platform: string,
// suggestedDay: string,
// suggestedTimeUtc: string,
// suggestedTimeLocal: string,
// reason: string
// }],
// contentStrengths: string[],
// contentWeaknesses: string[],
// generatedAt: string
// }- Persists with
type: 'prediction'. - Emits
ai:predictedevent. - Throws
NotFoundErrorifcontentIddoes not exist.
suite.ai.generateAltText(imageUrl, options?)
Generate WCAG-compliant alt text and caption for an image URL. Operates on an
image URL directly — does not require a contentId.
const result = await suite.ai.generateAltText('https://example.com/photo.jpg', {
contentContext: 'Hero image for a blog post about TypeScript',
language: 'en', // default: 'en'
maxAltTextLength: 125, // default: 125
maxCaptionLength: 250, // default: 250
});
// result: AiAltTextResult
// {
// imageUrl: string,
// altText: string,
// altTextLength: number,
// caption: string,
// captionLength: number,
// isDecorative: boolean,
// imageDescription: string,
// generatedAt: string
// }- Throws
ProviderCapabilityErrorif the configured provider does not support vision. - Throws
InternalErrorif noaltTextServiceis configured. - Storage key is a SHA-256 hash of the normalized
imageUrl. - Persists with
type: 'alt-text'. - Emits
ai:alt-text:generatedevent.
AI Events
| Event | Payload |
|-------|---------|
| ai:tagged | AiTagResult |
| ai:excerpts:generated | AiExcerptResult |
| ai:seo:analyzed | AiSeoResult |
| ai:predicted | AiPredictionResult |
| ai:alt-text:generated | AiAltTextResult |
Errors
| Class | Code | When thrown |
|-------|------|-------------|
| NotFoundError | NOT_FOUND | contentId does not exist (tag, enhance, suggest, predict) |
| InternalError | INTERNAL_ERROR | Required service not configured |
| ProviderCapabilityError | PROVIDER_CAPABILITY_ERROR | Provider lacks required capability (e.g. vision for alt text) |
All error classes are exported from the package entry point.
License
Copyright (c) 2025 Bernier LLC. Licensed under limited-use license.
