miles990

content-platforms

CMS, blogging platforms, and content management patterns

miles990 20 6 Updated 8mo ago

Resources

1
GitHub

Install

npx skillscat add miles990/claude-software-skills/content-platforms

Install via the SkillsCat registry.

About this skill

This skill defines patterns and schemas for content management systems and blogging platforms, including headless CMS structures, field types, and media handling. It helps developers design, implement, and integrate structured content workflows, solving the need for flexible content modeling and publishing. It is appropriate for building content‑driven applications, blogs, or any system requiring organized content management.

SKILL.md

Content Platforms

Overview

Building content management systems, blogging platforms, and rich media applications.


Content Models

Headless CMS Schema

// Content types
interface ContentType {
  id: string;
  name: string;
  slug: string;
  fields: Field[];
  settings: ContentTypeSettings;
}

interface Field {
  id: string;
  name: string;
  type: FieldType;
  required: boolean;
  localized: boolean;
  validation?: FieldValidation;
}

type FieldType =
  | 'text'
  | 'richText'
  | 'number'
  | 'boolean'
  | 'date'
  | 'media'
  | 'reference'
  | 'array'
  | 'json';

// Blog post content type
const blogPostType: ContentType = {
  id: 'blogPost',
  name: 'Blog Post',
  slug: 'blog-posts',
  fields: [
    { id: 'title', name: 'Title', type: 'text', required: true, localized: true },
    { id: 'slug', name: 'Slug', type: 'text', required: true, localized: false },
    { id: 'content', name: 'Content', type: 'richText', required: true, localized: true },
    { id: 'excerpt', name: 'Excerpt', type: 'text', required: false, localized: true },
    { id: 'featuredImage', name: 'Featured Image', type: 'media', required: false, localized: false },
    { id: 'author', name: 'Author', type: 'reference', required: true, localized: false },
    { id: 'tags', name: 'Tags', type: 'array', required: false, localized: false },
    { id: 'publishedAt', name: 'Published At', type: 'date', required: false, localized: false },
    { id: 'seo', name: 'SEO', type: 'json', required: false, localized: true },
  ],
  settings: {
    previewable: true,
    versionable: true,
    publishable: true,
  },
};

// Prisma schema
/*
model Content {
  id            String   @id @default(cuid())
  contentTypeId String
  status        String   @default("draft")
  data          Json
  locale        String   @default("en")
  version       Int      @default(1)
  publishedAt   DateTime?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  @@index([contentTypeId, status])
  @@index([contentTypeId, locale])
}
*/

Rich Text Editor

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';

function RichTextEditor({
  content,
  onChange,
}: {
  content: string;
  onChange: (content: string) => void;
}) {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Image.configure({ inline: true }),
      Link.configure({ openOnClick: false }),
      Placeholder.configure({ placeholder: 'Start writing...' }),
    ],
    content,
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
  });

  if (!editor) return null;

  return (
    <div className="editor-wrapper">
      <MenuBar editor={editor} />
      <EditorContent editor={editor} className="prose max-w-none" />
    </div>
  );
}

function MenuBar({ editor }: { editor: Editor }) {
  return (
    <div className="menu-bar">
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={editor.isActive('bold') ? 'active' : ''}
      >
        Bold
      </button>
      <button
        onClick={() => editor.chain().focus().toggleItalic().run()}
        className={editor.isActive('italic') ? 'active' : ''}
      >
        Italic
      </button>
      <button
        onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
        className={editor.isActive('heading', { level: 2 }) ? 'active' : ''}
      >
        H2
      </button>
      <button
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        className={editor.isActive('bulletList') ? 'active' : ''}
      >
        Bullet List
      </button>
      <button
        onClick={() => editor.chain().focus().toggleCodeBlock().run()}
        className={editor.isActive('codeBlock') ? 'active' : ''}
      >
        Code Block
      </button>
      <button onClick={() => addImage(editor)}>Image</button>
      <button onClick={() => addLink(editor)}>Link</button>
    </div>
  );
}

Media Management

import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import sharp from 'sharp';

const s3 = new S3Client({ region: process.env.AWS_REGION });

interface MediaAsset {
  id: string;
  filename: string;
  mimeType: string;
  size: number;
  url: string;
  thumbnailUrl?: string;
  width?: number;
  height?: number;
  alt?: string;
}

// Upload with image processing
async function uploadMedia(file: Express.Multer.File): Promise<MediaAsset> {
  const id = crypto.randomUUID();
  const extension = path.extname(file.originalname);
  const key = `media/${id}${extension}`;

  let processedBuffer = file.buffer;
  let width: number | undefined;
  let height: number | undefined;

  // Process images
  if (file.mimetype.startsWith('image/')) {
    const image = sharp(file.buffer);
    const metadata = await image.metadata();
    width = metadata.width;
    height = metadata.height;

    // Resize if too large
    if (width && width > 2000) {
      processedBuffer = await image
        .resize(2000, null, { withoutEnlargement: true })
        .toBuffer();
    }

    // Generate thumbnail
    const thumbnail = await image
      .resize(300, 300, { fit: 'cover' })
      .webp({ quality: 80 })
      .toBuffer();

    await s3.send(new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: `thumbnails/${id}.webp`,
      Body: thumbnail,
      ContentType: 'image/webp',
    }));
  }

  // Upload original
  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Body: processedBuffer,
    ContentType: file.mimetype,
  }));

  // Save to database
  return prisma.media.create({
    data: {
      id,
      filename: file.originalname,
      mimeType: file.mimetype,
      size: processedBuffer.length,
      url: `${process.env.CDN_URL}/${key}`,
      thumbnailUrl: file.mimetype.startsWith('image/')
        ? `${process.env.CDN_URL}/thumbnails/${id}.webp`
        : undefined,
      width,
      height,
    },
  });
}

// Image optimization on-the-fly (with caching)
async function getOptimizedImage(
  key: string,
  options: { width?: number; height?: number; format?: 'webp' | 'avif' | 'jpeg' }
) {
  const cacheKey = `optimized/${key}/${JSON.stringify(options)}`;

  // Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return Buffer.from(cached, 'base64');
  }

  // Get original
  const original = await s3.send(new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
  }));

  // Process
  let image = sharp(await original.Body?.transformToByteArray());

  if (options.width || options.height) {
    image = image.resize(options.width, options.height, {
      fit: 'inside',
      withoutEnlargement: true,
    });
  }

  if (options.format) {
    image = image.toFormat(options.format, { quality: 80 });
  }

  const buffer = await image.toBuffer();

  // Cache for 1 hour
  await redis.setex(cacheKey, 3600, buffer.toString('base64'));

  return buffer;
}

Content Versioning

interface ContentVersion {
  id: string;
  contentId: string;
  version: number;
  data: Record<string, any>;
  createdBy: string;
  createdAt: Date;
  changeDescription?: string;
}

// Create new version
async function createVersion(
  contentId: string,
  data: Record<string, any>,
  userId: string,
  description?: string
) {
  const current = await prisma.content.findUnique({
    where: { id: contentId },
  });

  // Save current as version
  await prisma.contentVersion.create({
    data: {
      contentId,
      version: current.version,
      data: current.data,
      createdBy: userId,
      changeDescription: description,
    },
  });

  // Update content
  return prisma.content.update({
    where: { id: contentId },
    data: {
      data,
      version: { increment: 1 },
    },
  });
}

// Get version history
async function getVersionHistory(contentId: string) {
  return prisma.contentVersion.findMany({
    where: { contentId },
    orderBy: { version: 'desc' },
    include: {
      createdByUser: { select: { name: true, avatar: true } },
    },
  });
}

// Restore version
async function restoreVersion(contentId: string, versionNumber: number, userId: string) {
  const version = await prisma.contentVersion.findFirst({
    where: { contentId, version: versionNumber },
  });

  if (!version) {
    throw new Error('Version not found');
  }

  return createVersion(contentId, version.data, userId, `Restored from version ${versionNumber}`);
}

// Diff between versions
function diffVersions(oldVersion: ContentVersion, newVersion: ContentVersion) {
  // Using deep-diff or similar library
  const diff = require('deep-diff');
  return diff(oldVersion.data, newVersion.data);
}

Publishing Workflow

enum ContentStatus {
  DRAFT = 'draft',
  IN_REVIEW = 'in_review',
  APPROVED = 'approved',
  PUBLISHED = 'published',
  ARCHIVED = 'archived',
}

// Workflow transitions
const workflowTransitions: Record<ContentStatus, ContentStatus[]> = {
  [ContentStatus.DRAFT]: [ContentStatus.IN_REVIEW],
  [ContentStatus.IN_REVIEW]: [ContentStatus.DRAFT, ContentStatus.APPROVED],
  [ContentStatus.APPROVED]: [ContentStatus.IN_REVIEW, ContentStatus.PUBLISHED],
  [ContentStatus.PUBLISHED]: [ContentStatus.ARCHIVED],
  [ContentStatus.ARCHIVED]: [ContentStatus.DRAFT],
};

async function transitionContent(
  contentId: string,
  newStatus: ContentStatus,
  userId: string,
  comment?: string
) {
  const content = await prisma.content.findUnique({ where: { id: contentId } });

  const allowedTransitions = workflowTransitions[content.status];
  if (!allowedTransitions.includes(newStatus)) {
    throw new Error(`Cannot transition from ${content.status} to ${newStatus}`);
  }

  // Log transition
  await prisma.contentWorkflowLog.create({
    data: {
      contentId,
      fromStatus: content.status,
      toStatus: newStatus,
      userId,
      comment,
    },
  });

  // Update content
  return prisma.content.update({
    where: { id: contentId },
    data: {
      status: newStatus,
      ...(newStatus === ContentStatus.PUBLISHED && { publishedAt: new Date() }),
    },
  });
}

// Schedule publishing
async function schedulePublish(contentId: string, publishAt: Date) {
  await prisma.content.update({
    where: { id: contentId },
    data: {
      scheduledPublishAt: publishAt,
      status: ContentStatus.APPROVED,
    },
  });

  // Queue job
  await queue.add('publish-content', { contentId }, {
    delay: publishAt.getTime() - Date.now(),
  });
}

SEO & Metadata

interface SEOMetadata {
  title: string;
  description: string;
  keywords?: string[];
  ogImage?: string;
  ogType?: string;
  canonical?: string;
  noIndex?: boolean;
}

function generateSEOTags(meta: SEOMetadata, url: string) {
  return {
    title: meta.title,
    meta: [
      { name: 'description', content: meta.description },
      meta.keywords && { name: 'keywords', content: meta.keywords.join(', ') },
      meta.noIndex && { name: 'robots', content: 'noindex, nofollow' },

      // Open Graph
      { property: 'og:title', content: meta.title },
      { property: 'og:description', content: meta.description },
      { property: 'og:type', content: meta.ogType || 'article' },
      { property: 'og:url', content: url },
      meta.ogImage && { property: 'og:image', content: meta.ogImage },

      // Twitter
      { name: 'twitter:card', content: 'summary_large_image' },
      { name: 'twitter:title', content: meta.title },
      { name: 'twitter:description', content: meta.description },
      meta.ogImage && { name: 'twitter:image', content: meta.ogImage },
    ].filter(Boolean),
    link: [
      meta.canonical && { rel: 'canonical', href: meta.canonical },
    ].filter(Boolean),
  };
}

Related Skills

  • [[frontend]] - Content rendering
  • [[database]] - Content storage
  • [[cloud-platforms]] - Media hosting