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();