feat: initial release of pengzhan.dev personal editorial blog platform

- Editorial web platform built with Node.js, Express, EJS, and Vanilla CSS
- Markdown content engine with YAML frontmatter, bilingual variants, and hot-reload watcher
- Unified SQLite with FTS5 search indexing, query logging, and schema filters
- Centered editorial post layout with margin-docked left TOC and mobile bottom sheet
- WordPress-grade comment system with moderation dashboard and bulk operations under /guanli
- Complete SEO suite (JSON-LD structured data, RSS/Atom feeds, XML sitemap, robots.txt)
- CI/CD workflows for linting, security audits (Gitleaks), automated tests, and Docker releases
This commit is contained in:
2026-09-16 23:08:53 -07:00
parent ac2c4c09ef
commit 8c0b04db0e
126 changed files with 8515 additions and 3984 deletions
+262
View File
@@ -0,0 +1,262 @@
const express = require('express');
const router = express.Router();
const commentService = require('../services/comments');
const contentService = require('../services/content');
const pageCache = require('../middleware/cache');
const { loadSiteConfig } = require('../config');
// Helper to parse cookies from headers without requiring extra npm dependencies
function parseCookies(req) {
const list = {};
const rc = req.headers.cookie;
if (rc) {
rc.split(';').forEach(cookie => {
const parts = cookie.split('=');
const key = parts.shift().trim();
const val = parts.join('=').trim();
try {
list[key] = decodeURIComponent(val);
} catch (_) {
list[key] = val;
}
});
}
return list;
}
function getExpectedAdminKey() {
const config = loadSiteConfig();
return process.env.ADMIN_SECRET || config.admin?.secret || config.site?.admin_secret || 'admin';
}
// Admin authentication middleware
function requireAdminAuth(req, res, next) {
const expectedKey = getExpectedAdminKey();
const cookies = parseCookies(req);
const providedKey = req.query.key || req.headers['x-admin-key'] || cookies.admin_key;
if (providedKey && providedKey === expectedKey) {
// If authenticated via query param, set cookie for convenience
if (req.query.key) {
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
}
return next();
}
// Render clean login prompt if unauthorized
res.status(401);
return res.render('pages/admin-login', {
title: 'Admin Authorization',
error: providedKey ? 'Incorrect admin password.' : null,
redirect: req.originalUrl
});
}
// Explicit 404 for legacy /admin routes to prevent leakage and bust browser 301 cache
router.all(['/admin', '/admin/*'], (req, res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.status(404).render('pages/error', {
title: '404 - Page Not Found',
errorCode: 404,
errorMessage: "The page you are looking for doesn't exist or has moved."
});
});
// Redirect /guanli root to comments
router.get('/guanli', (req, res) => {
res.redirect('/guanli/comments');
});
// Login GET endpoint (clean access)
router.get('/guanli/login', (req, res) => {
res.render('pages/admin-login', {
title: 'Admin Authorization',
error: null,
redirect: req.query.redirect || '/guanli/comments'
});
});
// Login POST endpoint
router.post('/guanli/login', (req, res) => {
const expectedKey = getExpectedAdminKey();
const providedKey = (req.body.key || '').trim();
const redirectUrl = req.body.redirect || '/guanli/comments';
if (providedKey === expectedKey) {
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
return res.redirect(redirectUrl);
}
res.status(401).render('pages/admin-login', {
title: 'Admin Authorization',
error: 'Incorrect admin password. Please try again.',
redirect: redirectUrl
});
});
// Logout endpoint
router.get('/guanli/logout', (req, res) => {
res.setHeader('Set-Cookie', 'admin_key=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT');
res.redirect('/guanli/comments');
});
const dbService = require('../services/db');
// 1. Comment Moderation Dashboard
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
const status = req.query.status || 'all';
const q = req.query.q || '';
const page = parseInt(req.query.page, 10) || 1;
const limit = 15;
const paginated = dbService.getPaginatedComments({ status, q, page, limit });
const counts = dbService.getCommentCounts();
const adminKey = getExpectedAdminKey();
const stats = dbService.getAdminStats();
res.render('pages/admin-comments', {
title: 'Comment Moderation Dashboard',
comments: paginated.comments,
totalComments: paginated.total,
page: paginated.page,
totalPages: paginated.totalPages,
status,
q,
counts,
adminKey,
stats
});
});
// 2a. Single Moderation Action Endpoint
router.post('/guanli/comments/:action/:id', requireAdminAuth, (req, res) => {
const { action, id } = req.params;
if (action === 'approve') {
commentService.approveComment(Number(id));
pageCache.clear();
} else if (action === 'reject' || action === 'spam') {
commentService.rejectComment(Number(id));
pageCache.clear();
} else if (action === 'delete') {
commentService.deleteComment(Number(id));
pageCache.clear();
}
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
if (isAjax) {
const counts = dbService.getCommentCounts();
const stats = dbService.getAdminStats();
return res.json({ success: true, action, id: Number(id), counts, stats });
}
const returnUrl = req.headers.referer || '/guanli/comments';
res.redirect(returnUrl);
});
// 2b. Batch Moderation Action Endpoint
router.post('/guanli/comments/batch', requireAdminAuth, (req, res) => {
const action = req.body.action;
let ids = req.body.ids;
if (typeof ids === 'string') {
ids = ids.split(',').map(s => Number(s.trim())).filter(Boolean);
} else if (Array.isArray(ids)) {
ids = ids.map(Number).filter(Boolean);
} else {
ids = [];
}
if (ids.length > 0) {
if (action === 'approve') {
commentService.batchApproveComments(ids);
pageCache.clear();
} else if (action === 'reject' || action === 'spam') {
commentService.batchRejectComments(ids);
pageCache.clear();
} else if (action === 'delete') {
commentService.batchDeleteComments(ids);
pageCache.clear();
}
}
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
if (isAjax) {
const counts = dbService.getCommentCounts();
const stats = dbService.getAdminStats();
return res.json({ success: true, action, ids, count: ids.length, counts, stats });
}
const returnUrl = req.headers.referer || '/guanli/comments';
res.redirect(returnUrl);
});
// 3. Dynamic SEO Analyzer Dashboard
router.get('/guanli/seo', requireAdminAuth, (req, res) => {
const posts = contentService.getAllPosts();
const adminKey = getExpectedAdminKey();
const stats = dbService.getAdminStats();
const report = [];
for (const post of posts) {
const raw = post.rawContent || '';
const internalLinks = (raw.match(/\[[^\]]+\]\((\/[^)]+)\)/g) || []).length;
const externalLinks = (raw.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/g) || []).length;
const imagesMissingAlt = (raw.match(/!\[\s*\]\([^)]+\)/g) || []).length;
const totalImages = (raw.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
// Check heading hierarchy
const headings = post.toc || [];
let headingIssues = [];
let lastLevel = 1;
for (const h of headings) {
if (h.level > lastLevel + 1) {
headingIssues.push(`Skipped heading level: from h${lastLevel} to h${h.level} ("${h.title}")`);
}
lastLevel = h.level;
}
const wordCount = raw.trim().split(/\s+/).length;
const titleLength = post.title.length;
report.push({
type: 'post',
slug: post.slug,
title: post.title,
wordCount,
titleLength,
titleOk: titleLength >= 20 && titleLength <= 70,
internalLinks,
externalLinks,
totalImages,
imagesMissingAlt,
headingIssues,
hasExcerpt: Boolean(post.excerpt && post.excerpt.length > 50)
});
}
res.render('pages/admin-seo', {
title: 'SEO Analyzer & Content Audit',
report,
adminKey,
stats
});
});
// 4. Search Analytics Dashboard
router.get('/guanli/search', requireAdminAuth, (req, res) => {
const adminKey = getExpectedAdminKey();
const stats = dbService.getAdminStats();
const searchLogs = dbService.getAllSearchLogs(100);
const recommendations = dbService.getTopSearchRecommendations(20);
res.render('pages/admin-search', {
title: 'Search Analytics & Query Logs',
adminKey,
stats,
searchLogs,
recommendations
});
});
module.exports = router;
+42
View File
@@ -0,0 +1,42 @@
const express = require('express');
const router = express.Router();
const contentService = require('../services/content');
router.get('/blogs', (req, res) => {
let posts = contentService.getAllPosts();
const { category, tag, page } = req.query;
let activeFilter = null;
if (category) {
const catLower = category.trim().toLowerCase();
posts = posts.filter(p => (p.category || '').toLowerCase() === catLower);
activeFilter = { type: 'category', value: category };
} else if (tag) {
const tagLower = tag.trim().toLowerCase();
posts = posts.filter(p => (p.tags || []).some(t => t.toLowerCase() === tagLower));
activeFilter = { type: 'tag', value: tag };
}
const postsPerPage = 12;
const currentPage = Math.max(1, parseInt(page, 10) || 1);
const totalPosts = posts.length;
const totalPages = Math.ceil(totalPosts / postsPerPage);
const startIndex = (currentPage - 1) * postsPerPage;
const paginatedPosts = posts.slice(startIndex, startIndex + postsPerPage);
const taxonomy = contentService.getTaxonomy();
res.render('pages/blogs', {
title: activeFilter ? `Blogs - ${activeFilter.type}: ${activeFilter.value}` : 'Blogs',
posts: paginatedPosts,
currentPage,
totalPages,
totalPosts,
activeFilter,
taxonomy
});
});
module.exports = router;
+60
View File
@@ -0,0 +1,60 @@
const express = require('express');
const router = express.Router();
const commentService = require('../services/comments');
const contentService = require('../services/content');
const pluginManager = require('../services/plugin-manager');
router.get('/api/comments/:slug', (req, res) => {
const comments = commentService.getCommentsForPost(req.params.slug);
res.json({ comments });
});
router.post('/api/comments', async (req, res) => {
try {
const { postSlug, authorName, authorEmail, authorWebsite, body, honeypot } = req.body;
if (!postSlug || !authorName || !body) {
return res.status(400).json({ error: 'Missing required fields' });
}
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const newComment = commentService.addComment({
postSlug,
authorName,
authorEmail,
authorWebsite,
body,
honeypot,
ip: clientIp
});
// Invalidate page cache so new comment and admin views update immediately
const pageCache = require('../middleware/cache');
pageCache.clear();
const post = contentService.getPost(postSlug);
// Fire plugin lifecycle hook asynchronously
pluginManager.triggerCommentCreated(newComment, post).catch(err => {
console.error('[CommentRoute] Error in onCommentCreated plugin hook:', err);
});
// If submitted via AJAX, return JSON
if (req.xhr || req.headers.accept.includes('json')) {
return res.json({
success: true,
comment: newComment,
message: newComment.approved === 1 ? 'Comment posted!' : 'Your comment is awaiting moderation.'
});
}
// Otherwise redirect back to post comments anchor with flash message
const msg = newComment.approved === 1 ? 'comment_posted' : 'comment_pending';
res.redirect(`/${postSlug}?comment_status=${msg}#comments`);
} catch (err) {
console.error('[CommentRoute] Error saving comment:', err);
res.status(500).json({ error: 'Failed to post comment' });
}
});
module.exports = router;
+58
View File
@@ -0,0 +1,58 @@
const express = require('express');
const router = express.Router();
const { Feed } = require('feed');
const contentService = require('../services/content');
function generateFeed(site) {
const feed = new Feed({
title: site.title,
description: site.tagline,
id: site.url,
link: site.url,
language: 'en',
image: `${site.url}/images/og-default.png`,
favicon: `${site.url}/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}, ${site.author ? site.author.name : site.title}`,
author: {
name: site.author ? site.author.name : site.title,
link: site.url
}
});
const posts = contentService.getAllPosts();
for (const post of posts) {
const postUrl = `${site.url}/${post.slug}`;
feed.addItem({
title: post.title,
id: postUrl,
link: postUrl,
description: post.excerpt,
content: post.contentHtml,
author: [
{
name: post.author,
link: site.url
}
],
date: post.date,
image: post.cover ? (post.cover.startsWith('http') ? post.cover : `${site.url}${post.cover}`) : undefined
});
}
return feed;
}
router.get(['/feed', '/rss.xml', '/feed.xml'], (req, res) => {
const feed = generateFeed(res.locals.site);
res.header('Content-Type', 'application/xml; charset=utf-8');
res.send(feed.rss2());
});
router.get('/atom.xml', (req, res) => {
const feed = generateFeed(res.locals.site);
res.header('Content-Type', 'application/atom+xml; charset=utf-8');
res.send(feed.atom1());
});
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const contentService = require('../services/content');
router.get('/', (req, res) => {
const homePage = contentService.getPage('/');
const recentPosts = contentService.getAllPosts().slice(0, 6);
res.render('pages/home', {
title: homePage ? homePage.title : res.locals.site.title,
page: homePage,
recentPosts
});
});
module.exports = router;
+28
View File
@@ -0,0 +1,28 @@
const express = require('express');
const router = express.Router();
const contentService = require('../services/content');
router.get('/:slug', (req, res, next) => {
const pagePath = `/${req.params.slug}`;
const page = contentService.getPage(pagePath);
if (!page) {
return next(); // Fall through to 404
}
res.locals.seo = {
title: `${page.title} - ${res.locals.site.title}`,
description: page.frontmatter.description || res.locals.site.tagline,
canonicalUrl: `${res.locals.site.url}${pagePath}`,
ogImage: res.locals.seo.ogImage,
type: 'website'
};
const layout = page.layout === 'home' ? 'pages/home' : 'pages/page';
res.render(layout, {
title: page.title,
page
});
});
module.exports = router;
+52
View File
@@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const contentService = require('../services/content');
const commentService = require('../services/comments');
function renderPost(req, res, next, slug, lang) {
const post = contentService.getPost(slug, lang);
if (!post) {
return next(); // Fall through to standalone pages or 404
}
const { prev, next: nextPost } = contentService.getAdjacentPosts(slug);
const comments = commentService.getCommentsForPost(slug);
// Set SEO metadata for post
res.locals.seo = {
title: `${post.title} - ${res.locals.site.title}`,
description: post.excerpt || res.locals.site.tagline,
canonicalUrl: `${res.locals.site.url}/${post.slug}${post.lang !== 'en' && !post.isDefault ? `/${post.lang}` : ''}`,
ogImage: post.cover ? (post.cover.startsWith('http') ? post.cover : `${res.locals.site.url}${post.cover}`) : res.locals.seo.ogImage,
type: 'article',
publishedTime: post.date.toISOString(),
author: post.author
};
res.render('pages/post', {
title: post.title,
post,
currentLang: lang || post.lang,
comments,
commentStatus: req.query.comment_status,
prevPost: prev,
nextPost
});
}
// Route for bilingual variant: /<post-slug>/<lang> (e.g. /my-post/zh)
router.get('/:slug/:lang([a-zA-Z]{2,5})', (req, res, next) => {
renderPost(req, res, next, req.params.slug, req.params.lang);
});
// Route for default language: /<post-slug>
router.get('/:slug', (req, res, next) => {
// Exclude reserved top-level prefixes if any
const reserved = ['blogs', 'search', 'quick-links', 'feed', 'rss.xml', 'sitemap.xml', 'robots.txt', 'api', 'plugins', 'public', 'posts-assets', 'guanli', 'admin'];
if (reserved.includes(req.params.slug)) {
return next();
}
renderPost(req, res, next, req.params.slug, null);
});
module.exports = router;
+104
View File
@@ -0,0 +1,104 @@
const express = require('express');
const router = express.Router();
const searchEngine = require('../services/search-engine');
const contentService = require('../services/content');
const { loadSiteConfig } = require('../config');
function getRecommendedSearches() {
const config = loadSiteConfig();
const taxonomy = contentService.getTaxonomy();
const dbService = require('../services/db');
const dynamicPills = [];
// 1. Add categories as discovery pills
for (const cat of taxonomy.categories) {
dynamicPills.push({
label: `category:${cat.name.toLowerCase()}`,
query: `category:${cat.name.toLowerCase()}`,
count: cat.count
});
}
// 2. Add top tags as discovery pills
for (const tag of taxonomy.tags.slice(0, 6)) {
dynamicPills.push({
label: `tag:${tag.name.toLowerCase()}`,
query: `tag:${tag.name.toLowerCase()}`,
count: tag.count
});
}
// 3. Add top trending searches from SQLite database
const trendingDb = dbService.getTopSearchRecommendations(4);
const customPills = [];
for (const item of trendingDb) {
if (!dynamicPills.some(p => p.query === item.query)) {
customPills.push({
label: item.label,
query: item.query
});
}
}
// 4. Add any static curated pills from config
const staticConfig = (config.search && config.search.recommended) || [];
for (const item of staticConfig) {
if (!dynamicPills.some(p => p.query === item.query) && !customPills.some(p => p.query === item.query)) {
customPills.push(item);
}
}
return {
dynamicPills,
customPills
};
}
router.get('/search', (req, res) => {
const query = (req.query.q || '').trim();
const recommended = getRecommendedSearches();
let searchData = {
query: { filters: {}, phrases: [], keywords: [], raw: '' },
results: [],
total: 0
};
if (query) {
searchData = searchEngine.search(query);
}
res.render('pages/search', {
title: query ? `Search: ${query}` : 'Search',
query,
searchData,
recommended
});
});
// JSON API endpoint for client-side search autocomplete / live search
router.get('/api/search', (req, res) => {
const query = (req.query.q || '').trim();
if (!query) {
return res.json({ query: null, results: [], total: 0 });
}
const searchData = searchEngine.search(query);
res.json({
query: searchData.query,
total: searchData.total,
results: searchData.results.map(r => ({
slug: r.post.slug,
lang: r.post.lang,
title: r.post.title,
date: r.post.date,
category: r.post.category,
tags: r.post.tags,
score: r.score,
snippet: r.snippet
}))
});
});
module.exports = router;
+75
View File
@@ -0,0 +1,75 @@
const express = require('express');
const router = express.Router();
const contentService = require('../services/content');
router.get('/sitemap.xml', (req, res) => {
const siteUrl = res.locals.site.url;
const posts = contentService.getAllPosts();
const pages = Array.from(contentService.pagesBySlug.values());
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
// Static / Top-level pages
const staticRoutes = ['/', '/blogs', '/search'];
for (const route of staticRoutes) {
xml += ' <url>\n';
xml += ` <loc>${siteUrl}${route}</loc>\n`;
xml += ` <changefreq>daily</changefreq>\n`;
xml += ` <priority>${route === '/' ? '1.0' : '0.8'}</priority>\n`;
xml += ' </url>\n';
}
// Standalone pages
for (const page of pages) {
if (!page.slug || page.slug === '/') continue;
const cleanSlug = page.slug.startsWith('/') ? page.slug : `/${page.slug}`;
if (cleanSlug === '/guanli' || cleanSlug.startsWith('/guanli/') || cleanSlug === '/admin' || cleanSlug.startsWith('/admin/')) {
continue;
}
xml += ' <url>\n';
xml += ` <loc>${siteUrl}${cleanSlug}</loc>\n`;
xml += ` <changefreq>weekly</changefreq>\n`;
xml += ` <priority>0.7</priority>\n`;
xml += ' </url>\n';
}
// Blog posts (including translations)
for (const post of posts) {
if (!post.slug || post.slug.startsWith('guanli') || post.slug.startsWith('admin')) continue;
xml += ' <url>\n';
xml += ` <loc>${siteUrl}/${post.slug}</loc>\n`;
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
xml += ` <changefreq>monthly</changefreq>\n`;
xml += ` <priority>0.9</priority>\n`;
xml += ' </url>\n';
// Bilingual URLs
if (post.availableLanguages) {
for (const lang of post.availableLanguages) {
if (lang !== post.lang && lang !== 'default') {
xml += ' <url>\n';
xml += ` <loc>${siteUrl}/${post.slug}/${lang}</loc>\n`;
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
xml += ` <changefreq>monthly</changefreq>\n`;
xml += ` <priority>0.8</priority>\n`;
xml += ' </url>\n';
}
}
}
}
xml += '</urlset>';
res.header('Content-Type', 'application/xml; charset=utf-8');
res.send(xml);
});
router.get('/robots.txt', (req, res) => {
const siteUrl = res.locals.site.url;
const content = `User-agent: *\nDisallow: /admin/\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`;
res.header('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
});
module.exports = router;