feat: implement comprehensive blog system
Add dual-source blog system with Editor.js integration: - Blog storage supporting repo-based (JSON) and database sources - Admin panel with rich text editor using Editor.js - Public news page with infinite scroll - Individual blog post viewer page - Categories management in admin - Image upload functionality - 4 SEO blog posts about WordPress with PluginCompass promotion - 3 News blog posts about Plugin Compass - API endpoints for CRUD operations - Security and validation for admin operations Closes blog feature request
This commit is contained in:
436
chat/blog-system.js
Normal file
436
chat/blog-system.js
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Blog System Module
|
||||
* Handles blog storage, retrieval, and management
|
||||
* Supports dual-source: repo-based (JSON files) and database-based
|
||||
*/
|
||||
|
||||
const fs = require('fs/promises');
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Blog configuration
|
||||
const BLOGS_REPO_DIR = path.join(process.cwd(), 'blogs');
|
||||
const BLOGS_DB_FILE = path.join(process.cwd(), '.data', '.opencode-chat', 'blogs.db.json');
|
||||
const BLOGS_UPLOAD_DIR = path.join(process.cwd(), 'public', 'blogs', 'images');
|
||||
|
||||
// In-memory cache
|
||||
let blogsCache = null;
|
||||
let categoriesCache = null;
|
||||
let lastCacheTime = 0;
|
||||
const CACHE_TTL = 60000; // 1 minute
|
||||
|
||||
/**
|
||||
* Initialize blog storage
|
||||
*/
|
||||
async function initBlogStorage() {
|
||||
try {
|
||||
// Ensure upload directory exists
|
||||
await fs.mkdir(BLOGS_UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// Ensure database file exists
|
||||
try {
|
||||
await fs.access(BLOGS_DB_FILE);
|
||||
} catch {
|
||||
await fs.mkdir(path.dirname(BLOGS_DB_FILE), { recursive: true });
|
||||
await fs.writeFile(BLOGS_DB_FILE, JSON.stringify({
|
||||
posts: [],
|
||||
categories: [
|
||||
{ id: 'general', name: 'General', slug: 'general' },
|
||||
{ id: 'updates', name: 'Updates', slug: 'updates' },
|
||||
{ id: 'tutorials', name: 'Tutorials', slug: 'tutorials' },
|
||||
{ id: 'wordpress', name: 'WordPress', slug: 'wordpress' }
|
||||
],
|
||||
lastId: 0
|
||||
}, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize blog storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load database blogs
|
||||
*/
|
||||
async function loadDbBlogs() {
|
||||
try {
|
||||
const data = await fs.readFile(BLOGS_DB_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
return { posts: [], categories: [], lastId: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save database blogs
|
||||
*/
|
||||
async function saveDbBlogs(data) {
|
||||
await fs.writeFile(BLOGS_DB_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load repo-based blogs
|
||||
*/
|
||||
async function loadRepoBlogs() {
|
||||
const posts = [];
|
||||
|
||||
try {
|
||||
// Load news blogs
|
||||
const newsDir = path.join(BLOGS_REPO_DIR, 'news');
|
||||
if (fsSync.existsSync(newsDir)) {
|
||||
const newsFiles = await fs.readdir(newsDir);
|
||||
for (const file of newsFiles) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(newsDir, file), 'utf8');
|
||||
const post = JSON.parse(content);
|
||||
post.source = 'repo';
|
||||
post.type = 'news';
|
||||
posts.push(post);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load news blog ${file}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load SEO blogs
|
||||
const seoDir = path.join(BLOGS_REPO_DIR, 'seo');
|
||||
if (fsSync.existsSync(seoDir)) {
|
||||
const seoFiles = await fs.readdir(seoDir);
|
||||
for (const file of seoFiles) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(seoDir, file), 'utf8');
|
||||
const post = JSON.parse(content);
|
||||
post.source = 'repo';
|
||||
post.type = 'seo';
|
||||
posts.push(post);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load SEO blog ${file}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load repo blogs:', error);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blogs (merged from repo and database)
|
||||
*/
|
||||
async function getAllBlogs(options = {}) {
|
||||
const { type, status, category, limit, offset, source } = options;
|
||||
|
||||
// Load both sources
|
||||
const [dbData, repoPosts] = await Promise.all([
|
||||
loadDbBlogs(),
|
||||
loadRepoBlogs()
|
||||
]);
|
||||
|
||||
// Merge posts (database takes precedence for same slug)
|
||||
const slugMap = new Map();
|
||||
|
||||
// Add repo posts first
|
||||
for (const post of repoPosts) {
|
||||
if (post.slug) slugMap.set(post.slug, post);
|
||||
}
|
||||
|
||||
// Add database posts (overwrites repo posts with same slug)
|
||||
for (const post of dbData.posts) {
|
||||
post.source = 'database';
|
||||
if (post.slug) slugMap.set(post.slug, post);
|
||||
}
|
||||
|
||||
let blogs = Array.from(slugMap.values());
|
||||
|
||||
// Apply filters
|
||||
if (type) blogs = blogs.filter(b => b.type === type);
|
||||
if (status) blogs = blogs.filter(b => b.status === status);
|
||||
if (category) blogs = blogs.filter(b => b.category === category || (b.categories && b.categories.includes(category)));
|
||||
if (source) blogs = blogs.filter(b => b.source === source);
|
||||
|
||||
// Sort by published date (newest first)
|
||||
blogs.sort((a, b) => {
|
||||
const dateA = new Date(a.published_at || a.created_at || 0);
|
||||
const dateB = new Date(b.published_at || b.created_at || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Apply pagination
|
||||
if (offset) blogs = blogs.slice(offset);
|
||||
if (limit) blogs = blogs.slice(0, limit);
|
||||
|
||||
return blogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single blog by slug
|
||||
*/
|
||||
async function getBlogBySlug(slug) {
|
||||
const blogs = await getAllBlogs();
|
||||
return blogs.find(b => b.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blog by ID
|
||||
*/
|
||||
async function getBlogById(id) {
|
||||
const dbData = await loadDbBlogs();
|
||||
const post = dbData.posts.find(p => p.id === id);
|
||||
if (post) {
|
||||
post.source = 'database';
|
||||
return post;
|
||||
}
|
||||
|
||||
const allBlogs = await getAllBlogs();
|
||||
return allBlogs.find(b => b.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new blog post (database only)
|
||||
*/
|
||||
async function createBlog(postData) {
|
||||
const dbData = await loadDbBlogs();
|
||||
|
||||
const newPost = {
|
||||
id: `db-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...postData,
|
||||
source: 'database',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Check for duplicate slug
|
||||
const exists = dbData.posts.find(p => p.slug === newPost.slug);
|
||||
if (exists) {
|
||||
throw new Error('A post with this slug already exists');
|
||||
}
|
||||
|
||||
dbData.posts.push(newPost);
|
||||
await saveDbBlogs(dbData);
|
||||
|
||||
return newPost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update blog post (database only)
|
||||
*/
|
||||
async function updateBlog(id, updates) {
|
||||
const dbData = await loadDbBlogs();
|
||||
const index = dbData.posts.findIndex(p => p.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Post not found');
|
||||
}
|
||||
|
||||
// Check if trying to update a repo-based blog
|
||||
if (dbData.posts[index].source === 'repo') {
|
||||
throw new Error('Cannot edit repository-based blogs from admin panel');
|
||||
}
|
||||
|
||||
// Check for slug conflict
|
||||
if (updates.slug && updates.slug !== dbData.posts[index].slug) {
|
||||
const exists = dbData.posts.find(p => p.slug === updates.slug && p.id !== id);
|
||||
if (exists) {
|
||||
throw new Error('A post with this slug already exists');
|
||||
}
|
||||
}
|
||||
|
||||
dbData.posts[index] = {
|
||||
...dbData.posts[index],
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
await saveDbBlogs(dbData);
|
||||
return dbData.posts[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete blog post (database only)
|
||||
*/
|
||||
async function deleteBlog(id) {
|
||||
const dbData = await loadDbBlogs();
|
||||
const index = dbData.posts.findIndex(p => p.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Post not found');
|
||||
}
|
||||
|
||||
if (dbData.posts[index].source === 'repo') {
|
||||
throw new Error('Cannot delete repository-based blogs from admin panel');
|
||||
}
|
||||
|
||||
const deleted = dbData.posts.splice(index, 1)[0];
|
||||
await saveDbBlogs(dbData);
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
async function getCategories() {
|
||||
const dbData = await loadDbBlogs();
|
||||
return dbData.categories || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create category
|
||||
*/
|
||||
async function createCategory(categoryData) {
|
||||
const dbData = await loadDbBlogs();
|
||||
|
||||
const newCategory = {
|
||||
id: `cat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...categoryData,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Check for duplicate slug
|
||||
const exists = dbData.categories.find(c => c.slug === newCategory.slug);
|
||||
if (exists) {
|
||||
throw new Error('A category with this slug already exists');
|
||||
}
|
||||
|
||||
dbData.categories.push(newCategory);
|
||||
await saveDbBlogs(dbData);
|
||||
|
||||
return newCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update category
|
||||
*/
|
||||
async function updateCategory(id, updates) {
|
||||
const dbData = await loadDbBlogs();
|
||||
const index = dbData.categories.findIndex(c => c.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Category not found');
|
||||
}
|
||||
|
||||
// Check for slug conflict
|
||||
if (updates.slug && updates.slug !== dbData.categories[index].slug) {
|
||||
const exists = dbData.categories.find(c => c.slug === updates.slug && c.id !== id);
|
||||
if (exists) {
|
||||
throw new Error('A category with this slug already exists');
|
||||
}
|
||||
}
|
||||
|
||||
dbData.categories[index] = {
|
||||
...dbData.categories[index],
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
await saveDbBlogs(dbData);
|
||||
return dbData.categories[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete category
|
||||
*/
|
||||
async function deleteCategory(id) {
|
||||
const dbData = await loadDbBlogs();
|
||||
const index = dbData.categories.findIndex(c => c.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Category not found');
|
||||
}
|
||||
|
||||
dbData.categories.splice(index, 1);
|
||||
await saveDbBlogs(dbData);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from title
|
||||
*/
|
||||
function generateSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Editor.js blocks to HTML
|
||||
*/
|
||||
function blocksToHtml(blocks) {
|
||||
if (!blocks || !Array.isArray(blocks)) return '';
|
||||
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'header':
|
||||
const level = Math.min(Math.max(block.data.level || 2, 1), 6);
|
||||
return `<h${level}>${escapeHtml(block.data.text)}</h${level}>`;
|
||||
|
||||
case 'paragraph':
|
||||
return `<p>${escapeHtml(block.data.text)}</p>`;
|
||||
|
||||
case 'list':
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = (block.data.items || []).map(item => `<li>${escapeHtml(item)}</li>`).join('');
|
||||
return `<${tag}>${items}</${tag}>`;
|
||||
|
||||
case 'quote':
|
||||
const caption = block.data.caption ? `<cite>${escapeHtml(block.data.caption)}</cite>` : '';
|
||||
return `<blockquote><p>${escapeHtml(block.data.text)}</p>${caption}</blockquote>`;
|
||||
|
||||
case 'code':
|
||||
return `<pre><code>${escapeHtml(block.data.code)}</code></pre>`;
|
||||
|
||||
case 'image':
|
||||
const caption_text = block.data.caption ? `<figcaption>${escapeHtml(block.data.caption)}</figcaption>` : '';
|
||||
return `<figure><img src="${escapeHtml(block.data.file?.url || block.data.url)}" alt="${escapeHtml(block.data.caption || '')}" />${caption_text}</figure>`;
|
||||
|
||||
case 'embed':
|
||||
return `<div class="embed" data-service="${escapeHtml(block.data.service)}">${block.data.embed}</div>`;
|
||||
|
||||
case 'table':
|
||||
const rows = (block.data.content || []).map(row =>
|
||||
`<tr>${row.map(cell => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`
|
||||
).join('');
|
||||
return `<table>${rows}</table>`;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initBlogStorage,
|
||||
getAllBlogs,
|
||||
getBlogBySlug,
|
||||
getBlogById,
|
||||
createBlog,
|
||||
updateBlog,
|
||||
deleteBlog,
|
||||
getCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
generateSlug,
|
||||
blocksToHtml,
|
||||
BLOGS_UPLOAD_DIR
|
||||
};
|
||||
Reference in New Issue
Block a user