Files
haopengzhan f52b3fe9e4 feat(migration): complete WordPress migration and UX refinements
- Migrate historical posts, localized assets, and 103 comments
- Colocate media into posts/<slug>/images/ and remove wp-content
- Add native bilingual support for ai-can-change post (English default, /zh variant)
- Support clean taxonomy routing (/category/:category and /tag/:tag)
- Dynamically populate categories and tags in sitemap.xml
- Fix header scroll padding/margin offsets for anchor and reference links
- Constrain image and media widths to prevent content overflow
- Redesign homepage recent posts to 4 compact editorial cards
- Restore valid binary favicon.ico and high-res icon assets
- Remove placeholder content and sample test comments
2026-09-16 23:09:09 -07:00

286 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;
v.defaultLang = defaultPost.lang;
}
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();