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:
@@ -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();
|
||||
Reference in New Issue
Block a user