- 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
285 lines
8.8 KiB
JavaScript
285 lines
8.8 KiB
JavaScript
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();
|