Files
shopify-ai-backup/chat/blog-system.js
southseact-3d cfd8d9c706 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
2026-02-10 13:23:37 +00:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
module.exports = {
initBlogStorage,
getAllBlogs,
getBlogBySlug,
getBlogById,
createBlog,
updateBlog,
deleteBlog,
getCategories,
createCategory,
updateCategory,
deleteCategory,
generateSlug,
blocksToHtml,
BLOGS_UPLOAD_DIR
};