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
436 lines
11 KiB
JavaScript
436 lines
11 KiB
JavaScript
/**
|
|
* 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
|
|
}; |