Blog Platform
Learn how to build a complete blog platform with Query-2jz, including content management, user authentication, and SEO optimization.
Project Overview
Build a full-featured blog platform with content management, user roles, comments, and advanced features.
Features
- • Content management system (CMS)
- • User authentication and roles
- • Post creation and editing
- • Categories and tags
- • Comments and moderation
- • SEO optimization
- • Search functionality
- • Analytics and insights
Project Structure
blog-platform/
├── src/
│ ├── models/
│ │ ├── User.ts
│ │ ├── Post.ts
│ │ ├── Category.ts
│ │ ├── Tag.ts
│ │ ├── Comment.ts
│ │ └── Media.ts
│ ├── services/
│ │ ├── PostService.ts
│ │ ├── UserService.ts
│ │ ├── CommentService.ts
│ │ └── SearchService.ts
│ ├── controllers/
│ │ ├── PostController.ts
│ │ ├── UserController.ts
│ │ └── CommentController.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ ├── validation.ts
│ │ └── seo.ts
│ ├── config/
│ │ └── query-2jz.config.ts
│ └── index.ts
├── package.json
├── tsconfig.json
└── README.mdData Models
Define comprehensive data models for the blog platform.
Post Model
// src/models/Post.ts
export interface Post {
id: string;
title: string;
slug: string;
excerpt: string;
content: string;
featuredImage?: string;
status: 'draft' | 'published' | 'archived';
visibility: 'public' | 'private' | 'password';
password?: string;
authorId: string;
categoryId?: string;
tags: string[];
seo: {
title?: string;
description?: string;
keywords?: string[];
canonicalUrl?: string;
ogImage?: string;
};
readingTime: number;
wordCount: number;
viewCount: number;
likeCount: number;
commentCount: number;
publishedAt?: Date;
scheduledAt?: Date;
createdAt: Date;
updatedAt: Date;
author?: User;
category?: Category;
comments?: Comment[];
media?: Media[];
}
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
color?: string;
icon?: string;
parentId?: string;
isActive: boolean;
postCount: number;
createdAt: Date;
updatedAt: Date;
parent?: Category;
children?: Category[];
posts?: Post[];
}
export interface Tag {
id: string;
name: string;
slug: string;
description?: string;
color?: string;
postCount: number;
createdAt: Date;
updatedAt: Date;
posts?: Post[];
}
export interface Comment {
id: string;
postId: string;
authorId?: string;
authorName: string;
authorEmail: string;
authorUrl?: string;
content: string;
status: 'pending' | 'approved' | 'spam' | 'trash';
parentId?: string;
ipAddress: string;
userAgent?: string;
isAuthor: boolean;
createdAt: Date;
updatedAt: Date;
post?: Post;
author?: User;
parent?: Comment;
replies?: Comment[];
}
export interface Media {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
url: string;
thumbnailUrl?: string;
alt?: string;
caption?: string;
uploadedBy: string;
postId?: string;
createdAt: Date;
updatedAt: Date;
uploader?: User;
post?: Post;
}User Model
// src/models/User.ts
export interface User {
id: string;
username: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
avatar?: string;
bio?: string;
website?: string;
location?: string;
role: 'admin' | 'editor' | 'author' | 'contributor' | 'subscriber';
status: 'active' | 'inactive' | 'suspended';
emailVerified: boolean;
lastLoginAt?: Date;
preferences: {
theme: 'light' | 'dark';
language: string;
timezone: string;
notifications: {
email: boolean;
push: boolean;
comments: boolean;
posts: boolean;
};
};
social: {
twitter?: string;
facebook?: string;
instagram?: string;
linkedin?: string;
github?: string;
};
stats: {
postCount: number;
commentCount: number;
viewCount: number;
likeCount: number;
};
createdAt: Date;
updatedAt: Date;
posts?: Post[];
comments?: Comment[];
media?: Media[];
}Query-2jz Configuration
Configure Query-2jz with all the necessary models and relationships for the blog platform.
// src/config/query-2jz.config.ts
import { Query-2jzConfig } from 'query-2jz';
export const query-2jzConfig: Query-2jzConfig = {
database: {
type: 'postgresql',
connection: process.env.DATABASE_URL
},
cache: {
type: 'redis',
connection: process.env.REDIS_URL
},
models: [
{
name: 'User',
fields: {
id: { type: 'id' },
username: { type: 'string', required: true, unique: true },
email: { type: 'email', required: true, unique: true },
displayName: { type: 'string', required: true },
firstName: { type: 'string' },
lastName: { type: 'string' },
avatar: { type: 'string' },
bio: { type: 'text' },
website: { type: 'string' },
location: { type: 'string' },
role: { type: 'enum', values: ['admin', 'editor', 'author', 'contributor', 'subscriber'], default: 'subscriber' },
status: { type: 'enum', values: ['active', 'inactive', 'suspended'], default: 'active' },
emailVerified: { type: 'boolean', default: false },
lastLoginAt: { type: 'date' },
preferences: { type: 'json', default: {} },
social: { type: 'json', default: {} },
stats: { type: 'json', default: { postCount: 0, commentCount: 0, viewCount: 0, likeCount: 0 } },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
posts: { type: 'relation', model: 'Post', many: true, foreignKey: 'authorId' },
comments: { type: 'relation', model: 'Comment', many: true, foreignKey: 'authorId' }
},
indexes: ['username', 'email', 'role', 'status']
},
{
name: 'Post',
fields: {
id: { type: 'id' },
title: { type: 'string', required: true },
slug: { type: 'string', required: true, unique: true },
excerpt: { type: 'text' },
content: { type: 'text', required: true },
featuredImage: { type: 'string' },
status: { type: 'enum', values: ['draft', 'published', 'archived'], default: 'draft' },
visibility: { type: 'enum', values: ['public', 'private', 'password'], default: 'public' },
password: { type: 'string' },
authorId: { type: 'string', required: true },
categoryId: { type: 'string' },
tags: { type: 'json', default: [] },
seo: { type: 'json', default: {} },
readingTime: { type: 'number', default: 0 },
wordCount: { type: 'number', default: 0 },
viewCount: { type: 'number', default: 0 },
likeCount: { type: 'number', default: 0 },
commentCount: { type: 'number', default: 0 },
publishedAt: { type: 'date' },
scheduledAt: { type: 'date' },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
author: { type: 'relation', model: 'User', foreignKey: 'authorId' },
category: { type: 'relation', model: 'Category', foreignKey: 'categoryId' },
comments: { type: 'relation', model: 'Comment', many: true, foreignKey: 'postId' }
},
indexes: ['slug', 'status', 'authorId', 'categoryId', 'publishedAt', 'tags']
},
{
name: 'Category',
fields: {
id: { type: 'id' },
name: { type: 'string', required: true },
slug: { type: 'string', required: true, unique: true },
description: { type: 'text' },
color: { type: 'string' },
icon: { type: 'string' },
parentId: { type: 'string' },
isActive: { type: 'boolean', default: true },
postCount: { type: 'number', default: 0 },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
parent: { type: 'relation', model: 'Category', foreignKey: 'parentId' },
children: { type: 'relation', model: 'Category', many: true, foreignKey: 'parentId' },
posts: { type: 'relation', model: 'Post', many: true, foreignKey: 'categoryId' }
},
indexes: ['slug', 'parentId', 'isActive']
},
{
name: 'Comment',
fields: {
id: { type: 'id' },
postId: { type: 'string', required: true },
authorId: { type: 'string' },
authorName: { type: 'string', required: true },
authorEmail: { type: 'email', required: true },
authorUrl: { type: 'string' },
content: { type: 'text', required: true },
status: { type: 'enum', values: ['pending', 'approved', 'spam', 'trash'], default: 'pending' },
parentId: { type: 'string' },
ipAddress: { type: 'string', required: true },
userAgent: { type: 'string' },
isAuthor: { type: 'boolean', default: false },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
post: { type: 'relation', model: 'Post', foreignKey: 'postId' },
author: { type: 'relation', model: 'User', foreignKey: 'authorId' },
parent: { type: 'relation', model: 'Comment', foreignKey: 'parentId' },
replies: { type: 'relation', model: 'Comment', many: true, foreignKey: 'parentId' }
},
indexes: ['postId', 'status', 'authorId', 'parentId', 'createdAt']
}
]
};Post Service
Implement business logic for post management and content operations.
Post Service Implementation
// src/services/PostService.ts
import { Query-2jz } from 'query-2jz';
import { Post } from '../models/Post';
export class PostService {
constructor(private query-2jz: Query-2jz) {}
async createPost(data: {
title: string;
content: string;
excerpt?: string;
authorId: string;
categoryId?: string;
tags?: string[];
status?: string;
visibility?: string;
seo?: any;
}) {
const slug = this.generateSlug(data.title);
// Calculate reading time and word count
const wordCount = this.countWords(data.content);
const readingTime = Math.ceil(wordCount / 200); // 200 words per minute
const post = await this.query-2jz.create({
model: 'Post',
data: {
title: data.title,
slug,
content: data.content,
excerpt: data.excerpt || this.generateExcerpt(data.content),
authorId: data.authorId,
categoryId: data.categoryId,
tags: data.tags || [],
status: data.status || 'draft',
visibility: data.visibility || 'public',
seo: data.seo || {},
readingTime,
wordCount
}
});
// Update category post count
if (data.categoryId) {
await this.updateCategoryPostCount(data.categoryId);
}
return post;
}
async updatePost(id: string, data: Partial<Post>) {
const existingPost = await this.query-2jz.query({
model: 'Post',
where: { id }
});
if (existingPost.data.length === 0) {
throw new Error('Post not found');
}
const updateData: any = { ...data };
// Regenerate slug if title changed
if (data.title && data.title !== existingPost.data[0].title) {
updateData.slug = this.generateSlug(data.title);
}
// Recalculate reading time and word count if content changed
if (data.content) {
updateData.wordCount = this.countWords(data.content);
updateData.readingTime = Math.ceil(updateData.wordCount / 200);
}
const updatedPost = await this.query-2jz.update({
model: 'Post',
id,
data: updateData
});
return updatedPost;
}
async publishPost(id: string) {
const post = await this.query-2jz.query({
model: 'Post',
where: { id }
});
if (post.data.length === 0) {
throw new Error('Post not found');
}
return this.query-2jz.update({
model: 'Post',
id,
data: {
status: 'published',
publishedAt: new Date()
}
});
}
async getPosts(filters: {
status?: string;
authorId?: string;
categoryId?: string;
tags?: string[];
search?: string;
limit?: number;
offset?: number;
}) {
const where: any = {};
if (filters.status) {
where.status = filters.status;
}
if (filters.authorId) {
where.authorId = filters.authorId;
}
if (filters.categoryId) {
where.categoryId = filters.categoryId;
}
if (filters.tags && filters.tags.length > 0) {
where.tags = { $overlap: filters.tags };
}
if (filters.search) {
where.$or = [
{ title: { $like: `%${filters.search}%` } },
{ content: { $like: `%${filters.search}%` } },
{ excerpt: { $like: `%${filters.search}%` } }
];
}
return this.query-2jz.query({
model: 'Post',
where,
include: ['author', 'category', 'comments'],
orderBy: 'publishedAt:desc',
limit: filters.limit || 10,
offset: filters.offset || 0
});
}
async getPost(slug: string) {
const post = await this.query-2jz.query({
model: 'Post',
where: { slug },
include: ['author', 'category', 'comments', 'comments.author']
});
if (post.data.length === 0) {
throw new Error('Post not found');
}
// Increment view count
await this.incrementViewCount(post.data[0].id);
return post.data[0];
}
async getRelatedPosts(postId: string, limit = 5) {
const post = await this.query-2jz.query({
model: 'Post',
where: { id: postId }
});
if (post.data.length === 0) {
return [];
}
const postData = post.data[0];
const where: any = {
id: { $ne: postId },
status: 'published'
};
// Find posts with same category or tags
if (postData.categoryId) {
where.categoryId = postData.categoryId;
}
if (postData.tags && postData.tags.length > 0) {
where.tags = { $overlap: postData.tags };
}
const relatedPosts = await this.query-2jz.query({
model: 'Post',
where,
include: ['author', 'category'],
orderBy: 'publishedAt:desc',
limit
});
return relatedPosts.data;
}
async incrementViewCount(postId: string) {
const post = await this.query-2jz.query({
model: 'Post',
where: { id: postId }
});
if (post.data.length > 0) {
await this.query-2jz.update({
model: 'Post',
id: postId,
data: {
viewCount: post.data[0].viewCount + 1
}
});
}
}
async updateCategoryPostCount(categoryId: string) {
const posts = await this.query-2jz.query({
model: 'Post',
where: { categoryId, status: 'published' }
});
await this.query-2jz.update({
model: 'Category',
id: categoryId,
data: {
postCount: posts.meta.total
}
});
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9 -]/g, '')
.replace(/s+/g, '-')
.replace(/-+/g, '-')
.trim('-');
}
private generateExcerpt(content: string, length = 160): string {
const plainText = content.replace(/<[^>]*>/g, '');
return plainText.length > length
? plainText.substring(0, length) + '...'
: plainText;
}
private countWords(text: string): number {
const plainText = text.replace(/<[^>]*>/g, '');
return plainText.split(/s+/).filter(word => word.length > 0).length;
}
}Comment Service
Implement comment management with moderation and spam detection.
// src/services/CommentService.ts
import { Query-2jz } from 'query-2jz';
import { Comment } from '../models/Comment';
export class CommentService {
constructor(private query-2jz: Query-2jz) {}
async createComment(data: {
postId: string;
authorId?: string;
authorName: string;
authorEmail: string;
authorUrl?: string;
content: string;
parentId?: string;
ipAddress: string;
userAgent?: string;
}) {
// Check if user is the post author
const post = await this.query-2jz.query({
model: 'Post',
where: { id: data.postId },
include: ['author']
});
if (post.data.length === 0) {
throw new Error('Post not found');
}
const isAuthor = data.authorId === post.data[0].authorId;
// Determine comment status based on moderation rules
const status = this.determineCommentStatus(data, isAuthor);
const comment = await this.query-2jz.create({
model: 'Comment',
data: {
postId: data.postId,
authorId: data.authorId,
authorName: data.authorName,
authorEmail: data.authorEmail,
authorUrl: data.authorUrl,
content: data.content,
status,
parentId: data.parentId,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
isAuthor
}
});
// Update post comment count
await this.updatePostCommentCount(data.postId);
return comment;
}
async getComments(postId: string, status = 'approved') {
return this.query-2jz.query({
model: 'Comment',
where: {
postId,
status,
parentId: null // Only top-level comments
},
include: ['author', 'replies', 'replies.author'],
orderBy: 'createdAt:asc'
});
}
async getCommentReplies(commentId: string) {
return this.query-2jz.query({
model: 'Comment',
where: {
parentId: commentId,
status: 'approved'
},
include: ['author'],
orderBy: 'createdAt:asc'
});
}
async moderateComment(commentId: string, status: 'approved' | 'spam' | 'trash') {
const comment = await this.query-2jz.query({
model: 'Comment',
where: { id: commentId }
});
if (comment.data.length === 0) {
throw new Error('Comment not found');
}
const updatedComment = await this.query-2jz.update({
model: 'Comment',
id: commentId,
data: { status }
});
// Update post comment count
await this.updatePostCommentCount(comment.data[0].postId);
return updatedComment;
}
async deleteComment(commentId: string) {
const comment = await this.query-2jz.query({
model: 'Comment',
where: { id: commentId }
});
if (comment.data.length === 0) {
throw new Error('Comment not found');
}
// Delete comment and all replies
await this.query-2jz.delete({
model: 'Comment',
where: {
$or: [
{ id: commentId },
{ parentId: commentId }
]
}
});
// Update post comment count
await this.updatePostCommentCount(comment.data[0].postId);
}
async getPendingComments(limit = 20, offset = 0) {
return this.query-2jz.query({
model: 'Comment',
where: { status: 'pending' },
include: ['post', 'author'],
orderBy: 'createdAt:desc',
limit,
offset
});
}
async getSpamComments(limit = 20, offset = 0) {
return this.query-2jz.query({
model: 'Comment',
where: { status: 'spam' },
include: ['post', 'author'],
orderBy: 'createdAt:desc',
limit,
offset
});
}
async bulkModerateComments(commentIds: string[], status: 'approved' | 'spam' | 'trash') {
const comments = await this.query-2jz.query({
model: 'Comment',
where: { id: { $in: commentIds } }
});
for (const comment of comments.data) {
await this.query-2jz.update({
model: 'Comment',
id: comment.id,
data: { status }
});
// Update post comment count
await this.updatePostCommentCount(comment.postId);
}
return comments.data;
}
private determineCommentStatus(data: any, isAuthor: boolean): string {
// Auto-approve comments from post authors
if (isAuthor) {
return 'approved';
}
// Check for spam indicators
if (this.isSpam(data)) {
return 'spam';
}
// Check if commenter has approved comments before
const hasApprovedComments = this.query-2jz.query({
model: 'Comment',
where: {
authorEmail: data.authorEmail,
status: 'approved'
},
limit: 1
});
// Auto-approve if user has approved comments
if (hasApprovedComments) {
return 'approved';
}
// Default to pending for moderation
return 'pending';
}
private isSpam(data: any): boolean {
const spamKeywords = ['viagra', 'casino', 'loan', 'free money'];
const content = data.content.toLowerCase();
// Check for spam keywords
if (spamKeywords.some(keyword => content.includes(keyword))) {
return true;
}
// Check for excessive links
const linkCount = (content.match(/https?:///g) || []).length;
if (linkCount > 2) {
return true;
}
// Check for excessive caps
const capsRatio = (content.match(/[A-Z]/g) || []).length / content.length;
if (capsRatio > 0.7) {
return true;
}
return false;
}
private async updatePostCommentCount(postId: string) {
const comments = await this.query-2jz.query({
model: 'Comment',
where: {
postId,
status: 'approved'
}
});
await this.query-2jz.update({
model: 'Post',
id: postId,
data: {
commentCount: comments.meta.total
}
});
}
}API Controllers
Create REST API controllers for the blog platform.
Post Controller
// src/controllers/PostController.ts
import { Request, Response } from 'express';
import { PostService } from '../services/PostService';
export class PostController {
constructor(private postService: PostService) {}
async getPosts(req: Request, res: Response) {
try {
const {
status = 'published',
authorId,
categoryId,
tags,
search,
limit = 10,
offset = 0
} = req.query;
const posts = await this.postService.getPosts({
status: status as string,
authorId: authorId as string,
categoryId: categoryId as string,
tags: tags ? (tags as string).split(',') : undefined,
search: search as string,
limit: parseInt(limit as string),
offset: parseInt(offset as string)
});
res.json(posts);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async getPost(req: Request, res: Response) {
try {
const { slug } = req.params;
const post = await this.postService.getPost(slug);
res.json(post);
} catch (error) {
res.status(404).json({ error: error.message });
}
}
async getRelatedPosts(req: Request, res: Response) {
try {
const { id } = req.params;
const { limit = 5 } = req.query;
const relatedPosts = await this.postService.getRelatedPosts(
id,
parseInt(limit as string)
);
res.json(relatedPosts);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async createPost(req: Request, res: Response) {
try {
const userId = req.user.id;
const postData = req.body;
const post = await this.postService.createPost({
...postData,
authorId: userId
});
res.status(201).json(post);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async updatePost(req: Request, res: Response) {
try {
const { id } = req.params;
const postData = req.body;
const post = await this.postService.updatePost(id, postData);
res.json(post);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async publishPost(req: Request, res: Response) {
try {
const { id } = req.params;
const post = await this.postService.publishPost(id);
res.json(post);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async deletePost(req: Request, res: Response) {
try {
const { id } = req.params;
await this.query-2jz.delete({ model: 'Post', id });
res.status(204).send();
} catch (error) {
res.status(500).json({ error: error.message });
}
}
}Comment Controller
// src/controllers/CommentController.ts
import { Request, Response } from 'express';
import { CommentService } from '../services/CommentService';
export class CommentController {
constructor(private commentService: CommentService) {}
async getComments(req: Request, res: Response) {
try {
const { postId } = req.params;
const { status = 'approved' } = req.query;
const comments = await this.commentService.getComments(
postId,
status as string
);
res.json(comments);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async createComment(req: Request, res: Response) {
try {
const { postId } = req.params;
const commentData = {
...req.body,
postId,
ipAddress: req.ip,
userAgent: req.get('User-Agent')
};
const comment = await this.commentService.createComment(commentData);
res.status(201).json(comment);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async moderateComment(req: Request, res: Response) {
try {
const { id } = req.params;
const { status } = req.body;
const comment = await this.commentService.moderateComment(id, status);
res.json(comment);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async getPendingComments(req: Request, res: Response) {
try {
const { limit = 20, offset = 0 } = req.query;
const comments = await this.commentService.getPendingComments(
parseInt(limit as string),
parseInt(offset as string)
);
res.json(comments);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async bulkModerateComments(req: Request, res: Response) {
try {
const { commentIds, status } = req.body;
const comments = await this.commentService.bulkModerateComments(
commentIds,
status
);
res.json(comments);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
}SEO Optimization
Implement SEO features for better search engine visibility.
// src/middleware/seo.ts
import { Request, Response, NextFunction } from 'express';
import { Query-2jz } from 'query-2jz';
export class SEOMiddleware {
constructor(private query-2jz: Query-2jz) {}
generateSitemap = async (req: Request, res: Response) => {
try {
const posts = await this.query-2jz.query({
model: 'Post',
where: { status: 'published' },
select: ['slug', 'updatedAt']
});
const categories = await this.query-2jz.query({
model: 'Category',
where: { isActive: true },
select: ['slug', 'updatedAt']
});
const sitemap = this.buildSitemap(posts.data, categories.data);
res.set('Content-Type', 'application/xml');
res.send(sitemap);
} catch (error) {
res.status(500).json({ error: error.message });
}
};
generateRobotsTxt = (req: Request, res: Response) => {
const robotsTxt = `User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: ${req.protocol}://${req.get('host')}/sitemap.xml`;
res.set('Content-Type', 'text/plain');
res.send(robotsTxt);
};
private buildSitemap(posts: any[], categories: any[]): string {
const baseUrl = process.env.BASE_URL || 'https://example.com';
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
// Add homepage
sitemap += `
<url>
<loc>${baseUrl}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>`;
// Add posts
posts.forEach(post => {
sitemap += `
<url>
<loc>${baseUrl}/posts/${post.slug}</loc>
<lastmod>${post.updatedAt}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`;
});
// Add categories
categories.forEach(category => {
sitemap += `
<url>
<loc>${baseUrl}/categories/${category.slug}</loc>
<lastmod>${category.updatedAt}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>`;
});
sitemap += `
</urlset>`;
return sitemap;
}
}Next Steps
Enhance your blog platform with additional features.
Advanced Features
- • Advanced search with filters
- • Content scheduling and automation
- • Email newsletters and subscriptions
- • Social media integration
- • Analytics and insights
- • Content versioning and history
- • Multi-language support