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
+49
View File
@@ -0,0 +1,49 @@
const dbService = require('./db');
class CommentService {
init(contentDir) {
dbService.init(contentDir);
}
getCommentsForPost(postSlug) {
return dbService.getApprovedCommentsForPost(postSlug);
}
addComment(params) {
return dbService.addComment(params);
}
getPendingComments() {
return dbService.getAllComments(0); // 0 = pending
}
getAllComments() {
return dbService.getAllComments();
}
approveComment(id) {
return dbService.updateCommentStatus(id, 1);
}
rejectComment(id) {
return dbService.updateCommentStatus(id, -1);
}
deleteComment(id) {
return dbService.deleteComment(id);
}
batchApproveComments(ids) {
return dbService.batchUpdateCommentStatus(ids, 1);
}
batchRejectComments(ids) {
return dbService.batchUpdateCommentStatus(ids, -1);
}
batchDeleteComments(ids) {
return dbService.batchDeleteComments(ids);
}
}
module.exports = new CommentService();
+284
View File
@@ -0,0 +1,284 @@
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const { createMarkdownRenderer, extractToc } = require('./markdown');
const searchEngine = require('./search-engine');
const pluginManager = require('./plugin-manager');
class ContentService {
constructor() {
this.contentDir = '';
this.postsBySlug = new Map(); // slug -> { defaultPost, variants: { lang -> post } }
this.allPostsList = []; // Chronological list of default language posts
this.pagesBySlug = new Map(); // slug -> page object
this.taxonomy = {
categories: new Map(), // name.toLowerCase() -> { name, count, posts }
tags: new Map() // name.toLowerCase() -> { name, slug, count, posts }
};
this.markdownRenderer = null;
}
init(contentDir) {
this.contentDir = contentDir;
this.markdownRenderer = createMarkdownRenderer(pluginManager);
this.reload();
}
reload() {
console.log('[ContentService] Scanning content directory:', this.contentDir);
this.postsBySlug.clear();
this.allPostsList = [];
this.pagesBySlug.clear();
this.taxonomy.categories.clear();
this.taxonomy.tags.clear();
// Re-initialize markdown renderer in case plugins changed
this.markdownRenderer = createMarkdownRenderer(pluginManager);
this.loadPosts();
this.loadPages();
// Update search engine with all searchable post variants
const allSearchable = [];
for (const entry of this.postsBySlug.values()) {
allSearchable.push(entry.defaultPost);
for (const [lang, variant] of Object.entries(entry.variants)) {
if (lang !== entry.defaultPost.lang) {
allSearchable.push(variant);
}
}
}
searchEngine.setPosts(allSearchable);
// Sync to SQLite database & FTS5
const dbService = require('./db');
dbService.syncPosts(allSearchable);
// Invalidate super page cache on content refresh
const pageCache = require('../middleware/cache');
pageCache.clear();
console.log(`[ContentService] Loaded ${this.allPostsList.length} blog posts and ${this.pagesBySlug.size} pages.`);
}
loadPosts() {
const postsDir = path.join(this.contentDir, 'posts');
if (!fs.existsSync(postsDir)) {
console.warn(`[ContentService] Posts directory not found at ${postsDir}`);
return;
}
const entries = fs.readdirSync(postsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const slug = entry.name;
const postDirPath = path.join(postsDir, slug);
// Find all markdown files in the post folder
const files = fs.readdirSync(postDirPath);
const mdFiles = files.filter(f => f.endsWith('.md'));
if (mdFiles.length === 0) continue;
const variants = {};
let defaultPost = null;
for (const file of mdFiles) {
const filePath = path.join(postDirPath, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content: rawMarkdown } = matter(fileContent);
// Determine language:
// if file is 'index.md', lang defaults to frontmatter.lang or 'en'
// if file is 'zh.md', lang is 'zh'
const baseName = path.basename(file, '.md');
const lang = frontmatter.lang || (baseName === 'index' ? 'en' : baseName);
const renderedHtml = this.markdownRenderer.render(rawMarkdown);
const toc = extractToc(rawMarkdown);
// Compute excerpt if not explicitly given
let excerpt = frontmatter.excerpt;
if (!excerpt) {
const firstParaMatch = rawMarkdown.replace(/#+\s+.+/g, '').trim().split(/\n\s*\n/)[0];
excerpt = firstParaMatch ? firstParaMatch.replace(/[*_`#[\]]/g, '').slice(0, 180) : '';
}
// Resolve cover image path relative to web root
let cover = frontmatter.cover || null;
if (cover && !cover.startsWith('http') && !cover.startsWith('/')) {
cover = `/posts-assets/${slug}/${cover}`;
}
// Tags normalization
const rawTags = Array.isArray(frontmatter.tags)
? frontmatter.tags
: (frontmatter.tags ? String(frontmatter.tags).split(',').map(s => s.trim()) : []);
const postObj = {
slug,
lang,
title: frontmatter.title || slug,
date: frontmatter.date ? new Date(frontmatter.date) : new Date(),
author: frontmatter.author || 'Pengzhan Hao',
category: frontmatter.category || 'Tech',
tags: rawTags,
cover,
excerpt,
rawContent: rawMarkdown,
contentHtml: renderedHtml,
toc,
isDefault: (baseName === 'index'),
availableLanguages: []
};
pluginManager.applyPostHooks(postObj);
variants[lang] = postObj;
if (baseName === 'index' || !defaultPost) {
defaultPost = postObj;
}
}
if (defaultPost) {
const availableLangs = Object.keys(variants);
defaultPost.availableLanguages = availableLangs;
for (const v of Object.values(variants)) {
v.availableLanguages = availableLangs;
}
this.postsBySlug.set(slug, {
slug,
defaultPost,
variants
});
this.allPostsList.push(defaultPost);
// Index Dynamic Taxonomy from default post
this.indexTaxonomy(defaultPost);
}
}
// Sort posts list in reverse chronological order
this.allPostsList.sort((a, b) => b.date.getTime() - a.date.getTime());
}
indexTaxonomy(post) {
// 1. Category indexing
if (post.category) {
const catKey = post.category.trim().toLowerCase();
if (!this.taxonomy.categories.has(catKey)) {
this.taxonomy.categories.set(catKey, {
name: post.category.trim(),
count: 0,
posts: []
});
}
const catObj = this.taxonomy.categories.get(catKey);
catObj.count++;
catObj.posts.push(post);
}
// 2. Tag indexing
if (Array.isArray(post.tags)) {
for (const tag of post.tags) {
if (!tag) continue;
const tagName = String(tag).trim();
const tagKey = tagName.toLowerCase();
const tagSlug = encodeURIComponent(tagKey.replace(/\s+/g, '-'));
if (!this.taxonomy.tags.has(tagKey)) {
this.taxonomy.tags.set(tagKey, {
name: tagName,
slug: tagSlug,
count: 0,
posts: []
});
}
const tagObj = this.taxonomy.tags.get(tagKey);
tagObj.count++;
tagObj.posts.push(post);
}
}
}
loadPages() {
const pagesDir = path.join(this.contentDir, 'pages');
if (!fs.existsSync(pagesDir)) {
return;
}
const files = fs.readdirSync(pagesDir);
for (const file of files) {
if (!file.endsWith('.md') && !file.endsWith('.html')) continue;
const filePath = path.join(pagesDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content: rawContent } = matter(fileContent);
const baseName = path.basename(file, path.extname(file));
const slug = frontmatter.permalink || (baseName === 'home' ? '/' : `/${baseName}`);
let contentHtml = '';
if (file.endsWith('.html')) {
contentHtml = rawContent;
} else {
contentHtml = this.markdownRenderer.render(rawContent);
}
const pageObj = {
slug,
title: frontmatter.title || baseName,
layout: frontmatter.layout || (baseName === 'home' ? 'home' : 'page'),
frontmatter,
rawContent,
contentHtml
};
this.pagesBySlug.set(slug, pageObj);
}
}
getAllPosts() {
return this.allPostsList;
}
getPost(slug, lang) {
const entry = this.postsBySlug.get(slug);
if (!entry) return null;
if (lang && entry.variants[lang]) {
return entry.variants[lang];
}
return entry.defaultPost;
}
getPage(slug) {
return this.pagesBySlug.get(slug) || null;
}
getTaxonomy() {
// Return structured taxonomy with top tags sorted by count
const categories = Array.from(this.taxonomy.categories.values())
.sort((a, b) => b.count - a.count);
const tags = Array.from(this.taxonomy.tags.values())
.sort((a, b) => b.count - a.count);
return { categories, tags };
}
getAdjacentPosts(currentSlug) {
const index = this.allPostsList.findIndex(p => p.slug === currentSlug);
if (index === -1) return { prev: null, next: null };
// List is sorted descending by date:
// next is newer (index - 1), prev is older (index + 1)
return {
next: index > 0 ? this.allPostsList[index - 1] : null,
prev: index < this.allPostsList.length - 1 ? this.allPostsList[index + 1] : null
};
}
}
module.exports = new ContentService();
+419
View File
@@ -0,0 +1,419 @@
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
class DatabaseService {
constructor() {
this.db = null;
this.dbPath = null;
}
init(contentDir) {
const dataDir = path.join(contentDir, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
this.dbPath = path.join(dataDir, 'site.db');
this.db = new DatabaseSync(this.dbPath);
// Initialize unified tables
this.db.exec(`
-- Posts storage & metadata
CREATE TABLE IF NOT EXISTS posts (
slug TEXT NOT NULL,
lang TEXT NOT NULL,
title TEXT NOT NULL,
date TEXT NOT NULL,
author TEXT,
category TEXT,
tags TEXT,
excerpt TEXT,
raw_content TEXT,
cover TEXT,
is_default INTEGER DEFAULT 1,
PRIMARY KEY (slug, lang)
);
-- Full Text Search virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
slug UNINDEXED,
lang UNINDEXED,
title,
category,
tags,
excerpt,
content,
tokenize='porter unicode61'
);
-- Search query analytics & history
CREATE TABLE IF NOT EXISTS search_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
result_count INTEGER DEFAULT 0,
searched_at TEXT NOT NULL
);
-- Search query recommendations (curated + dynamic)
CREATE TABLE IF NOT EXISTS search_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
query TEXT NOT NULL UNIQUE,
clicks INTEGER DEFAULT 0,
is_pinned INTEGER DEFAULT 0
);
-- Comments & Moderation Queue
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT,
author_website TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
approved INTEGER DEFAULT 0, -- 0: Pending moderation, 1: Approved, -1: Rejected/Spam
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(post_slug, approved);
CREATE INDEX IF NOT EXISTS idx_comments_email ON comments(author_email);
`);
console.log(`[DB] Unified SQLite database initialized at ${this.dbPath}`);
}
// --- Posts & FTS5 Synchronization ---
syncPosts(postsList) {
if (!this.db) return;
try {
this.db.exec('DELETE FROM posts; DELETE FROM posts_fts;');
const insertPost = this.db.prepare(`
INSERT INTO posts (slug, lang, title, date, author, category, tags, excerpt, raw_content, cover, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertFts = this.db.prepare(`
INSERT INTO posts_fts (slug, lang, title, category, tags, excerpt, content)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const post of postsList) {
const tagsStr = (post.tags || []).join(' ');
const dateStr = post.date instanceof Date ? post.date.toISOString() : String(post.date);
insertPost.run(
post.slug,
post.lang || 'en',
post.title || '',
dateStr,
post.author || '',
post.category || '',
tagsStr,
post.excerpt || '',
post.rawContent || '',
post.cover || '',
post.isDefault ? 1 : 0
);
insertFts.run(
post.slug,
post.lang || 'en',
post.title || '',
post.category || '',
tagsStr,
post.excerpt || '',
post.rawContent || ''
);
}
console.log(`[DB] Synced ${postsList.length} post variants to SQLite & FTS5.`);
} catch (err) {
console.error('[DB] Error syncing posts to SQLite:', err);
}
}
// --- Search Query Logging & Recommendations in DB ---
logSearch(query, resultCount = 0) {
if (!this.db || !query || !query.trim()) return;
try {
const trimmed = query.trim().slice(0, 150);
const stmt = this.db.prepare(`
INSERT INTO search_logs (query, result_count, searched_at)
VALUES (?, ?, ?)
`);
stmt.run(trimmed, resultCount, new Date().toISOString());
// Update recommendations clicks or add to recommendations if valid
const recStmt = this.db.prepare(`
INSERT INTO search_recommendations (label, query, clicks)
VALUES (?, ?, 1)
ON CONFLICT(query) DO UPDATE SET clicks = clicks + 1
`);
recStmt.run(trimmed, trimmed);
} catch (err) {
console.error('[DB] Error logging search:', err);
}
}
getTopSearchRecommendations(limit = 6) {
if (!this.db) return [];
try {
const stmt = this.db.prepare(`
SELECT label, query, clicks, is_pinned
FROM search_recommendations
ORDER BY is_pinned DESC, clicks DESC
LIMIT ?
`);
return stmt.all(limit);
} catch (err) {
console.error('[DB] Error fetching recommendations:', err);
return [];
}
}
// --- Comments & WordPress-Grade Moderation Workflow ---
isAuthorWhitelisted(email) {
if (!this.db || !email) return false;
try {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count FROM comments
WHERE author_email = ? AND approved = 1
`);
const res = stmt.get(email.toLowerCase().trim());
return res && res.count > 0;
} catch (err) {
return false;
}
}
addComment({ postSlug, authorName, authorEmail, authorWebsite, body, honeypot, ip }) {
if (!this.db) throw new Error('DB not initialized');
// Honeypot spam check
if (honeypot && honeypot.trim().length > 0) {
console.warn('[DB] Honeypot triggered, dropping spam.');
return { id: -1, status: 'spam' };
}
const cleanName = String(authorName).trim().slice(0, 80);
const cleanEmail = authorEmail ? String(authorEmail).toLowerCase().trim().slice(0, 120) : '';
const cleanWebsite = authorWebsite ? String(authorWebsite).trim().slice(0, 200) : '';
const cleanBody = String(body).trim().slice(0, 5000);
const createdAt = new Date().toISOString();
// WordPress-parity rule: If author's email was previously approved, auto-approve new comment!
const autoApprove = cleanEmail ? this.isAuthorWhitelisted(cleanEmail) : false;
const approvedStatus = autoApprove ? 1 : 0; // 0: pending moderation
const stmt = this.db.prepare(`
INSERT INTO comments (post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(postSlug, cleanName, cleanEmail, cleanWebsite, cleanBody, createdAt, approvedStatus, ip || '');
return {
post_slug: postSlug,
author_name: cleanName,
author_email: cleanEmail,
author_website: cleanWebsite,
body: cleanBody,
created_at: createdAt,
approved: approvedStatus,
status: approvedStatus === 1 ? 'approved' : 'pending'
};
}
getApprovedCommentsForPost(postSlug) {
if (!this.db) return [];
try {
const stmt = this.db.prepare(`
SELECT id, post_slug, author_name, author_website, body, created_at
FROM comments
WHERE post_slug = ? AND approved = 1
ORDER BY id ASC
`);
return stmt.all(postSlug);
} catch (err) {
console.error('[DB] Error fetching comments:', err);
return [];
}
}
getCommentCounts() {
if (!this.db) return { all: 0, pending: 0, approved: 0, spam: 0 };
try {
const all = this.db.prepare('SELECT COUNT(*) as c FROM comments').get().c;
const pending = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = 0').get().c;
const approved = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = 1').get().c;
const spam = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = -1').get().c;
return { all, pending, approved, spam };
} catch (err) {
console.error('[DB] Error fetching comment counts:', err);
return { all: 0, pending: 0, approved: 0, spam: 0 };
}
}
getPaginatedComments({ status = 'all', q = '', page = 1, limit = 15 } = {}) {
if (!this.db) return { comments: [], total: 0, page: 1, totalPages: 1 };
try {
let whereClauses = [];
let params = [];
if (status === 'pending') {
whereClauses.push('approved = 0');
} else if (status === 'approved') {
whereClauses.push('approved = 1');
} else if (status === 'spam') {
whereClauses.push('approved = -1');
}
if (q && q.trim()) {
whereClauses.push('(author_name LIKE ? OR author_email LIKE ? OR author_website LIKE ? OR body LIKE ? OR post_slug LIKE ? OR ip LIKE ?)');
const searchPattern = `%${q.trim()}%`;
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
}
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
const countStmt = this.db.prepare(`SELECT COUNT(*) as count FROM comments ${whereSql}`);
const total = countStmt.get(...params).count;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const pageSize = Math.max(1, parseInt(limit, 10) || 15);
const totalPages = Math.ceil(total / pageSize) || 1;
const offset = (pageNum - 1) * pageSize;
const dataStmt = this.db.prepare(`
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
FROM comments
${whereSql}
ORDER BY id DESC
LIMIT ? OFFSET ?
`);
const comments = dataStmt.all(...params, pageSize, offset);
return { comments, total, page: pageNum, totalPages, limit: pageSize };
} catch (err) {
console.error('[DB] Error in getPaginatedComments:', err);
return { comments: [], total: 0, page: 1, totalPages: 1 };
}
}
getAllComments(filterApproved = null) {
if (!this.db) return [];
try {
if (filterApproved !== null) {
const stmt = this.db.prepare(`
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
FROM comments
WHERE approved = ?
ORDER BY id DESC
`);
return stmt.all(filterApproved);
}
const stmt = this.db.prepare(`
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
FROM comments
ORDER BY id DESC
`);
return stmt.all();
} catch (err) {
console.error('[DB] Error fetching all comments:', err);
return [];
}
}
updateCommentStatus(commentId, newStatus) {
if (!this.db) return false;
try {
const stmt = this.db.prepare(`UPDATE comments SET approved = ? WHERE id = ?`);
stmt.run(newStatus, commentId);
return true;
} catch (err) {
console.error('[DB] Error updating comment status:', err);
return false;
}
}
deleteComment(commentId) {
if (!this.db) return false;
try {
const stmt = this.db.prepare(`DELETE FROM comments WHERE id = ?`);
stmt.run(commentId);
return true;
} catch (err) {
console.error('[DB] Error deleting comment:', err);
return false;
}
}
batchUpdateCommentStatus(ids, newStatus) {
if (!this.db || !Array.isArray(ids) || ids.length === 0) return 0;
try {
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`UPDATE comments SET approved = ? WHERE id IN (${placeholders})`);
const info = stmt.run(newStatus, ...ids);
return info.changes;
} catch (err) {
console.error('[DB] Error batch updating comments:', err);
return 0;
}
}
batchDeleteComments(ids) {
if (!this.db || !Array.isArray(ids) || ids.length === 0) return 0;
try {
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`DELETE FROM comments WHERE id IN (${placeholders})`);
const info = stmt.run(...ids);
return info.changes;
} catch (err) {
console.error('[DB] Error batch deleting comments:', err);
return 0;
}
}
// --- Admin Dashboard Stats & Logs ---
getAdminStats() {
if (!this.db) {
return { totalPosts: 0, pendingComments: 0, approvedComments: 0, totalSearches: 0 };
}
try {
const postsCount = this.db.prepare('SELECT COUNT(*) as count FROM posts').get()?.count || 0;
const pendingCount = this.db.prepare('SELECT COUNT(*) as count FROM comments WHERE approved = 0').get()?.count || 0;
const approvedCount = this.db.prepare('SELECT COUNT(*) as count FROM comments WHERE approved = 1').get()?.count || 0;
const searchCount = this.db.prepare('SELECT COUNT(*) as count FROM search_logs').get()?.count || 0;
return {
totalPosts: postsCount,
pendingComments: pendingCount,
approvedComments: approvedCount,
totalSearches: searchCount
};
} catch (err) {
console.error('[DB] Error getting admin stats:', err);
return { totalPosts: 0, pendingComments: 0, approvedComments: 0, totalSearches: 0 };
}
}
getAllSearchLogs(limit = 100) {
if (!this.db) return [];
try {
const stmt = this.db.prepare(`
SELECT id, query, result_count, searched_at
FROM search_logs
ORDER BY id DESC
LIMIT ?
`);
return stmt.all(limit);
} catch (err) {
console.error('[DB] Error fetching search logs:', err);
return [];
}
}
}
module.exports = new DatabaseService();
+88
View File
@@ -0,0 +1,88 @@
const MarkdownIt = require('markdown-it');
const markdownItAnchor = require('markdown-it-anchor');
const markdownItFootnote = require('markdown-it-footnote');
const markdownItAttrs = require('markdown-it-attrs');
const Prism = require('prismjs');
require('prismjs/components/prism-bash');
require('prismjs/components/prism-json');
require('prismjs/components/prism-yaml');
require('prismjs/components/prism-python');
require('prismjs/components/prism-go');
require('prismjs/components/prism-docker');
require('prismjs/components/prism-sql');
require('prismjs/components/prism-typescript');
require('prismjs/components/prism-markdown');
function slugifyHeading(s) {
return encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'));
}
function createMarkdownRenderer(pluginManager) {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && Prism.languages[lang]) {
try {
return `<pre class="language-${lang}"><code class="language-${lang}">${Prism.highlight(str, Prism.languages[lang], lang)}</code></pre>`;
} catch (__) {}
}
const escaped = md.utils.escapeHtml(str);
return `<pre class="language-text"><code class="language-text">${escaped}</code></pre>`;
}
});
md.use(markdownItAnchor, {
slugify: slugifyHeading,
permalink: markdownItAnchor.permalink.linkInsideHeader({
symbol: '#',
placement: 'after',
ariaHidden: true
})
});
md.use(markdownItFootnote);
md.use(markdownItAttrs);
// Disable indented code blocks (4 spaces) so indented HTML elements don't get converted to <pre><code>
md.disable('code');
if (pluginManager) {
pluginManager.applyMarkdownHooks(md);
}
return md;
}
function extractToc(markdownContent) {
const headings = [];
const lines = markdownContent.split(/\r?\n/);
let inCodeBlock = false;
for (const line of lines) {
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
// Strip markdown links/formatting from heading text for TOC display
const title = match[2].replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*_`]/g, '').trim();
const id = slugifyHeading(title);
headings.push({ level, title, id });
}
}
// Build hierarchical tree if needed or flat list with levels
return headings;
}
module.exports = {
createMarkdownRenderer,
extractToc,
slugifyHeading
};
+137
View File
@@ -0,0 +1,137 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
class PluginManager {
constructor() {
this.plugins = new Map();
this.pluginStaticRouters = [];
}
loadPlugins(contentDir, app, siteConfig) {
const pluginsDir = path.join(contentDir, 'plugins');
this.plugins.clear();
if (!fs.existsSync(pluginsDir)) {
return;
}
const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pluginName = entry.name;
const pluginPath = path.join(pluginsDir, pluginName);
const manifestPath = path.join(pluginPath, 'plugin.json');
const entryScriptPath = path.join(pluginPath, 'index.js');
if (!fs.existsSync(entryScriptPath)) {
continue;
}
try {
let manifest = { name: pluginName, enabled: true };
if (fs.existsSync(manifestPath)) {
manifest = { ...manifest, ...JSON.parse(fs.readFileSync(manifestPath, 'utf8')) };
}
// Check if enabled in site config (default true if not explicitly false)
const configEntry = siteConfig.plugins && siteConfig.plugins[pluginName];
if (configEntry === false || (configEntry && configEntry.enabled === false)) {
console.log(`[PluginManager] Plugin ${pluginName} is disabled in config`);
continue;
}
// Clear module cache for hot reload
delete require.cache[require.resolve(entryScriptPath)];
const pluginModule = require(entryScriptPath);
const pluginInstance = {
name: pluginName,
manifest,
module: pluginModule,
config: (configEntry && typeof configEntry === 'object') ? configEntry : {}
};
// Mount public assets if directory exists
const publicDir = path.join(pluginPath, 'public');
if (fs.existsSync(publicDir) && app) {
app.use(`/plugins/${pluginName}`, express.static(publicDir));
}
// Run onInit hook
if (typeof pluginModule.onInit === 'function' && app) {
try {
pluginModule.onInit({
app,
config: siteConfig,
pluginConfig: pluginInstance.config
});
} catch (initErr) {
console.error(`[PluginManager] Error in onInit for plugin ${pluginName}:`, initErr);
}
}
this.plugins.set(pluginName, pluginInstance);
console.log(`[PluginManager] Loaded plugin: ${pluginName}`);
} catch (err) {
console.error(`[PluginManager] Failed to load plugin ${pluginName}:`, err);
}
}
}
applyMarkdownHooks(markdownIt) {
for (const [name, plugin] of this.plugins) {
if (typeof plugin.module.onMarkdownInit === 'function') {
try {
plugin.module.onMarkdownInit({ markdownIt, pluginConfig: plugin.config });
} catch (err) {
console.error(`[PluginManager] Error in onMarkdownInit for ${name}:`, err);
}
}
}
}
applyPostHooks(post) {
for (const [name, plugin] of this.plugins) {
if (typeof plugin.module.onPostLoad === 'function') {
try {
plugin.module.onPostLoad({ post, pluginConfig: plugin.config });
} catch (err) {
console.error(`[PluginManager] Error in onPostLoad for ${name}:`, err);
}
}
}
}
async triggerCommentCreated(comment, post) {
for (const [name, plugin] of this.plugins) {
if (typeof plugin.module.onCommentCreated === 'function') {
try {
await plugin.module.onCommentCreated({ comment, post, pluginConfig: plugin.config });
} catch (err) {
console.error(`[PluginManager] Error in onCommentCreated for ${name}:`, err);
}
}
}
}
renderHook(hookName, context) {
let output = '';
for (const [name, plugin] of this.plugins) {
if (typeof plugin.module[hookName] === 'function') {
try {
const res = plugin.module[hookName]({ ...context, pluginConfig: plugin.config });
if (res && typeof res === 'string') {
output += '\n' + res;
}
} catch (err) {
console.error(`[PluginManager] Error in ${hookName} for ${name}:`, err);
}
}
}
return output;
}
}
module.exports = new PluginManager();
+243
View File
@@ -0,0 +1,243 @@
class SearchEngine {
constructor() {
this.posts = [];
}
setPosts(posts) {
this.posts = posts;
}
// Parse a query like: tag:k8s "container image" category:tech cgroup
parseQuery(queryString) {
if (!queryString || typeof queryString !== 'string') {
return { filters: {}, phrases: [], keywords: [], raw: '' };
}
const raw = queryString.trim();
const filters = {
tags: [],
categories: [],
authors: [],
langs: []
};
const phrases = [];
const keywords = [];
// Regex to match: field:value, field:"quoted value", "quoted phrase", or bare words
const tokenRegex = /(?:([a-zA-Z_-]+):(?:"([^"]+)"|([^\s]+)))|(?:"([^"]+)")|([^\s]+)/g;
let match;
while ((match = tokenRegex.exec(raw)) !== null) {
if (match[1]) {
// Schema filter (e.g. tag:k8s, category:tech)
const field = match[1].toLowerCase();
const value = (match[2] || match[3]).trim().toLowerCase();
if (field === 'tag' || field === 'tags') {
filters.tags.push(value);
} else if (field === 'category' || field === 'cat') {
filters.categories.push(value);
} else if (field === 'author') {
filters.authors.push(value);
} else if (field === 'lang') {
filters.langs.push(value);
} else {
// Unknown field, treat as keyword
keywords.push(`${field}:${value}`);
}
} else if (match[4]) {
// Quoted exact phrase
phrases.push(match[4].trim());
} else if (match[5]) {
// Bare keyword
keywords.push(match[5].trim());
}
}
return { filters, phrases, keywords, raw };
}
search(queryString) {
const parsed = this.parseQuery(queryString);
if (!parsed.raw) {
return { query: parsed, results: [], total: 0 };
}
const results = [];
for (const post of this.posts) {
let matches = true;
let score = 0;
// 1. Schema Filter Gate: Category
if (parsed.filters.categories.length > 0) {
const postCat = (post.category || '').toLowerCase();
const catMatch = parsed.filters.categories.every(cat => postCat === cat);
if (!catMatch) matches = false;
}
// 2. Schema Filter Gate: Tags
if (matches && parsed.filters.tags.length > 0) {
const postTags = (post.tags || []).map(t => String(t).toLowerCase());
const tagsMatch = parsed.filters.tags.every(reqTag =>
postTags.some(t => t === reqTag || t.replace(/[^a-z0-9]/g, '') === reqTag.replace(/[^a-z0-9]/g, ''))
);
if (!tagsMatch) matches = false;
}
// 3. Schema Filter Gate: Author
if (matches && parsed.filters.authors.length > 0) {
const postAuthor = (post.author || '').toLowerCase();
const authorMatch = parsed.filters.authors.every(a => postAuthor.includes(a));
if (!authorMatch) matches = false;
}
// 4. Schema Filter Gate: Lang
if (matches && parsed.filters.langs.length > 0) {
const postLang = (post.lang || 'en').toLowerCase();
const availableLangs = (post.availableLanguages || [postLang]).map(l => l.toLowerCase());
const langMatch = parsed.filters.langs.every(l => availableLangs.includes(l));
if (!langMatch) matches = false;
}
if (!matches) continue;
// Searchable text fields
const title = post.title || '';
const excerpt = post.excerpt || '';
const content = post.rawContent || '';
const tagsStr = (post.tags || []).join(' ');
const fullHaystack = `${title}\n${tagsStr}\n${excerpt}\n${content}`.toLowerCase();
// 5. Exact Phrase Matching (must match ALL quoted phrases)
let phraseMatches = true;
for (const phrase of parsed.phrases) {
const lowerPhrase = phrase.toLowerCase();
if (!fullHaystack.includes(lowerPhrase)) {
phraseMatches = false;
break;
}
// Score bonus for phrase
if (title.toLowerCase().includes(lowerPhrase)) score += 30;
if (excerpt.toLowerCase().includes(lowerPhrase)) score += 15;
if (content.toLowerCase().includes(lowerPhrase)) score += 10;
}
if (!phraseMatches) continue;
// 6. Bare Keywords Matching (must match ALL keywords)
let keywordMatches = true;
for (const kw of parsed.keywords) {
const lowerKw = kw.toLowerCase();
if (!fullHaystack.includes(lowerKw)) {
keywordMatches = false;
break;
}
if (title.toLowerCase().includes(lowerKw)) score += 15;
if (tagsStr.toLowerCase().includes(lowerKw)) score += 10;
if (excerpt.toLowerCase().includes(lowerKw)) score += 5;
if (content.toLowerCase().includes(lowerKw)) score += 2;
}
if (!keywordMatches) continue;
// Recency score bonus (up to 5 points)
if (post.date) {
const ageInDays = (Date.now() - new Date(post.date).getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays > 0) {
score += Math.max(0, 5 - Math.log10(ageInDays));
}
}
// Generate snippet with highlighted terms
const snippet = this.generateSnippet(post, parsed);
results.push({
post,
score,
snippet
});
}
// Deduplicate by post.slug so bilingual variants don't produce duplicate cards
const uniqueResultsMap = new Map();
for (const res of results) {
const existing = uniqueResultsMap.get(res.post.slug);
if (!existing || res.score > existing.score) {
uniqueResultsMap.set(res.post.slug, res);
}
}
const deduplicatedResults = Array.from(uniqueResultsMap.values());
// Sort by relevance score descending
deduplicatedResults.sort((a, b) => b.score - a.score);
// Log query to SQLite for trending recommendations & analytics
try {
const dbService = require('./db');
dbService.logSearch(queryString, deduplicatedResults.length);
} catch (e) {}
return {
query: parsed,
results: deduplicatedResults,
total: deduplicatedResults.length
};
}
generateSnippet(post, parsed) {
const text = post.rawContent || post.excerpt || '';
const searchTerms = [...parsed.phrases, ...parsed.keywords];
if (searchTerms.length === 0) {
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
}
// Find position of first matching term
let bestIndex = -1;
let matchedTerm = '';
const lowerText = text.toLowerCase();
for (const term of searchTerms) {
const idx = lowerText.indexOf(term.toLowerCase());
if (idx !== -1 && (bestIndex === -1 || idx < bestIndex)) {
bestIndex = idx;
matchedTerm = term;
}
}
if (bestIndex === -1) {
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
}
// Extract snippet around bestIndex
const start = Math.max(0, bestIndex - 60);
const end = Math.min(text.length, bestIndex + 100);
let snippet = text.slice(start, end).replace(/\s+/g, ' ');
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
// Highlight terms
let safeSnippet = this.escapeHtml(snippet);
for (const term of searchTerms) {
if (!term) continue;
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
safeSnippet = safeSnippet.replace(regex, '<mark>$1</mark>');
}
return safeSnippet;
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
module.exports = new SearchEngine();
+60
View File
@@ -0,0 +1,60 @@
const chokidar = require('chokidar');
const path = require('path');
const contentService = require('./content');
const pluginManager = require('./plugin-manager');
const { loadSiteConfig } = require('../config');
class WatcherService {
constructor() {
this.watcher = null;
this.debounceTimer = null;
}
start(contentDir, app) {
console.log(`[Watcher] Starting filesystem watcher on ${contentDir}`);
this.watcher = chokidar.watch(contentDir, {
ignored: [
/(^|[\/\\])\../, // dotfiles
/(^|[\/\\])data([\/\\]|$)/, // ignore entire data/ directory (site.db, journals, etc.)
/\.db(-journal|-wal|-shm)?$/, // sqlite database files
/node_modules/
],
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100
}
});
const triggerReload = (event, filePath) => {
const relative = path.relative(contentDir, filePath);
console.log(`[Watcher] Detected ${event} on ${relative}`);
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
try {
const freshConfig = loadSiteConfig();
pluginManager.loadPlugins(contentDir, app, freshConfig);
contentService.reload();
console.log(`[Watcher] Hot reload finished successfully.`);
} catch (err) {
console.error(`[Watcher] Error during hot reload:`, err);
}
}, 250);
};
this.watcher.on('add', (p) => triggerReload('add', p));
this.watcher.on('change', (p) => triggerReload('change', p));
this.watcher.on('unlink', (p) => triggerReload('unlink', p));
}
close() {
if (this.watcher) {
this.watcher.close();
}
}
}
module.exports = new WatcherService();