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,262 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const commentService = require('../services/comments');
|
||||
const contentService = require('../services/content');
|
||||
const pageCache = require('../middleware/cache');
|
||||
const { loadSiteConfig } = require('../config');
|
||||
|
||||
// Helper to parse cookies from headers without requiring extra npm dependencies
|
||||
function parseCookies(req) {
|
||||
const list = {};
|
||||
const rc = req.headers.cookie;
|
||||
if (rc) {
|
||||
rc.split(';').forEach(cookie => {
|
||||
const parts = cookie.split('=');
|
||||
const key = parts.shift().trim();
|
||||
const val = parts.join('=').trim();
|
||||
try {
|
||||
list[key] = decodeURIComponent(val);
|
||||
} catch (_) {
|
||||
list[key] = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function getExpectedAdminKey() {
|
||||
const config = loadSiteConfig();
|
||||
return process.env.ADMIN_SECRET || config.admin?.secret || config.site?.admin_secret || 'admin';
|
||||
}
|
||||
|
||||
// Admin authentication middleware
|
||||
function requireAdminAuth(req, res, next) {
|
||||
const expectedKey = getExpectedAdminKey();
|
||||
const cookies = parseCookies(req);
|
||||
const providedKey = req.query.key || req.headers['x-admin-key'] || cookies.admin_key;
|
||||
|
||||
if (providedKey && providedKey === expectedKey) {
|
||||
// If authenticated via query param, set cookie for convenience
|
||||
if (req.query.key) {
|
||||
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// Render clean login prompt if unauthorized
|
||||
res.status(401);
|
||||
return res.render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: providedKey ? 'Incorrect admin password.' : null,
|
||||
redirect: req.originalUrl
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit 404 for legacy /admin routes to prevent leakage and bust browser 301 cache
|
||||
router.all(['/admin', '/admin/*'], (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.status(404).render('pages/error', {
|
||||
title: '404 - Page Not Found',
|
||||
errorCode: 404,
|
||||
errorMessage: "The page you are looking for doesn't exist or has moved."
|
||||
});
|
||||
});
|
||||
|
||||
// Redirect /guanli root to comments
|
||||
router.get('/guanli', (req, res) => {
|
||||
res.redirect('/guanli/comments');
|
||||
});
|
||||
|
||||
// Login GET endpoint (clean access)
|
||||
router.get('/guanli/login', (req, res) => {
|
||||
res.render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: null,
|
||||
redirect: req.query.redirect || '/guanli/comments'
|
||||
});
|
||||
});
|
||||
|
||||
// Login POST endpoint
|
||||
router.post('/guanli/login', (req, res) => {
|
||||
const expectedKey = getExpectedAdminKey();
|
||||
const providedKey = (req.body.key || '').trim();
|
||||
const redirectUrl = req.body.redirect || '/guanli/comments';
|
||||
|
||||
if (providedKey === expectedKey) {
|
||||
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
return res.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
res.status(401).render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: 'Incorrect admin password. Please try again.',
|
||||
redirect: redirectUrl
|
||||
});
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.get('/guanli/logout', (req, res) => {
|
||||
res.setHeader('Set-Cookie', 'admin_key=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT');
|
||||
res.redirect('/guanli/comments');
|
||||
});
|
||||
|
||||
const dbService = require('../services/db');
|
||||
|
||||
// 1. Comment Moderation Dashboard
|
||||
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
|
||||
const status = req.query.status || 'all';
|
||||
const q = req.query.q || '';
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const limit = 15;
|
||||
|
||||
const paginated = dbService.getPaginatedComments({ status, q, page, limit });
|
||||
const counts = dbService.getCommentCounts();
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
|
||||
res.render('pages/admin-comments', {
|
||||
title: 'Comment Moderation Dashboard',
|
||||
comments: paginated.comments,
|
||||
totalComments: paginated.total,
|
||||
page: paginated.page,
|
||||
totalPages: paginated.totalPages,
|
||||
status,
|
||||
q,
|
||||
counts,
|
||||
adminKey,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
// 2a. Single Moderation Action Endpoint
|
||||
router.post('/guanli/comments/:action/:id', requireAdminAuth, (req, res) => {
|
||||
const { action, id } = req.params;
|
||||
|
||||
if (action === 'approve') {
|
||||
commentService.approveComment(Number(id));
|
||||
pageCache.clear();
|
||||
} else if (action === 'reject' || action === 'spam') {
|
||||
commentService.rejectComment(Number(id));
|
||||
pageCache.clear();
|
||||
} else if (action === 'delete') {
|
||||
commentService.deleteComment(Number(id));
|
||||
pageCache.clear();
|
||||
}
|
||||
|
||||
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
|
||||
if (isAjax) {
|
||||
const counts = dbService.getCommentCounts();
|
||||
const stats = dbService.getAdminStats();
|
||||
return res.json({ success: true, action, id: Number(id), counts, stats });
|
||||
}
|
||||
|
||||
const returnUrl = req.headers.referer || '/guanli/comments';
|
||||
res.redirect(returnUrl);
|
||||
});
|
||||
|
||||
// 2b. Batch Moderation Action Endpoint
|
||||
router.post('/guanli/comments/batch', requireAdminAuth, (req, res) => {
|
||||
const action = req.body.action;
|
||||
let ids = req.body.ids;
|
||||
|
||||
if (typeof ids === 'string') {
|
||||
ids = ids.split(',').map(s => Number(s.trim())).filter(Boolean);
|
||||
} else if (Array.isArray(ids)) {
|
||||
ids = ids.map(Number).filter(Boolean);
|
||||
} else {
|
||||
ids = [];
|
||||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
if (action === 'approve') {
|
||||
commentService.batchApproveComments(ids);
|
||||
pageCache.clear();
|
||||
} else if (action === 'reject' || action === 'spam') {
|
||||
commentService.batchRejectComments(ids);
|
||||
pageCache.clear();
|
||||
} else if (action === 'delete') {
|
||||
commentService.batchDeleteComments(ids);
|
||||
pageCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
|
||||
if (isAjax) {
|
||||
const counts = dbService.getCommentCounts();
|
||||
const stats = dbService.getAdminStats();
|
||||
return res.json({ success: true, action, ids, count: ids.length, counts, stats });
|
||||
}
|
||||
|
||||
const returnUrl = req.headers.referer || '/guanli/comments';
|
||||
res.redirect(returnUrl);
|
||||
});
|
||||
|
||||
// 3. Dynamic SEO Analyzer Dashboard
|
||||
router.get('/guanli/seo', requireAdminAuth, (req, res) => {
|
||||
const posts = contentService.getAllPosts();
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
const report = [];
|
||||
|
||||
for (const post of posts) {
|
||||
const raw = post.rawContent || '';
|
||||
const internalLinks = (raw.match(/\[[^\]]+\]\((\/[^)]+)\)/g) || []).length;
|
||||
const externalLinks = (raw.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/g) || []).length;
|
||||
const imagesMissingAlt = (raw.match(/!\[\s*\]\([^)]+\)/g) || []).length;
|
||||
const totalImages = (raw.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
|
||||
|
||||
// Check heading hierarchy
|
||||
const headings = post.toc || [];
|
||||
let headingIssues = [];
|
||||
let lastLevel = 1;
|
||||
for (const h of headings) {
|
||||
if (h.level > lastLevel + 1) {
|
||||
headingIssues.push(`Skipped heading level: from h${lastLevel} to h${h.level} ("${h.title}")`);
|
||||
}
|
||||
lastLevel = h.level;
|
||||
}
|
||||
|
||||
const wordCount = raw.trim().split(/\s+/).length;
|
||||
const titleLength = post.title.length;
|
||||
|
||||
report.push({
|
||||
type: 'post',
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
wordCount,
|
||||
titleLength,
|
||||
titleOk: titleLength >= 20 && titleLength <= 70,
|
||||
internalLinks,
|
||||
externalLinks,
|
||||
totalImages,
|
||||
imagesMissingAlt,
|
||||
headingIssues,
|
||||
hasExcerpt: Boolean(post.excerpt && post.excerpt.length > 50)
|
||||
});
|
||||
}
|
||||
|
||||
res.render('pages/admin-seo', {
|
||||
title: 'SEO Analyzer & Content Audit',
|
||||
report,
|
||||
adminKey,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Search Analytics Dashboard
|
||||
router.get('/guanli/search', requireAdminAuth, (req, res) => {
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
const searchLogs = dbService.getAllSearchLogs(100);
|
||||
const recommendations = dbService.getTopSearchRecommendations(20);
|
||||
|
||||
res.render('pages/admin-search', {
|
||||
title: 'Search Analytics & Query Logs',
|
||||
adminKey,
|
||||
stats,
|
||||
searchLogs,
|
||||
recommendations
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/blogs', (req, res) => {
|
||||
let posts = contentService.getAllPosts();
|
||||
const { category, tag, page } = req.query;
|
||||
|
||||
let activeFilter = null;
|
||||
|
||||
if (category) {
|
||||
const catLower = category.trim().toLowerCase();
|
||||
posts = posts.filter(p => (p.category || '').toLowerCase() === catLower);
|
||||
activeFilter = { type: 'category', value: category };
|
||||
} else if (tag) {
|
||||
const tagLower = tag.trim().toLowerCase();
|
||||
posts = posts.filter(p => (p.tags || []).some(t => t.toLowerCase() === tagLower));
|
||||
activeFilter = { type: 'tag', value: tag };
|
||||
}
|
||||
|
||||
const postsPerPage = 12;
|
||||
const currentPage = Math.max(1, parseInt(page, 10) || 1);
|
||||
const totalPosts = posts.length;
|
||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
||||
|
||||
const startIndex = (currentPage - 1) * postsPerPage;
|
||||
const paginatedPosts = posts.slice(startIndex, startIndex + postsPerPage);
|
||||
|
||||
const taxonomy = contentService.getTaxonomy();
|
||||
|
||||
res.render('pages/blogs', {
|
||||
title: activeFilter ? `Blogs - ${activeFilter.type}: ${activeFilter.value}` : 'Blogs',
|
||||
posts: paginatedPosts,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalPosts,
|
||||
activeFilter,
|
||||
taxonomy
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,60 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const commentService = require('../services/comments');
|
||||
const contentService = require('../services/content');
|
||||
const pluginManager = require('../services/plugin-manager');
|
||||
|
||||
router.get('/api/comments/:slug', (req, res) => {
|
||||
const comments = commentService.getCommentsForPost(req.params.slug);
|
||||
res.json({ comments });
|
||||
});
|
||||
|
||||
router.post('/api/comments', async (req, res) => {
|
||||
try {
|
||||
const { postSlug, authorName, authorEmail, authorWebsite, body, honeypot } = req.body;
|
||||
|
||||
if (!postSlug || !authorName || !body) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||
|
||||
const newComment = commentService.addComment({
|
||||
postSlug,
|
||||
authorName,
|
||||
authorEmail,
|
||||
authorWebsite,
|
||||
body,
|
||||
honeypot,
|
||||
ip: clientIp
|
||||
});
|
||||
|
||||
// Invalidate page cache so new comment and admin views update immediately
|
||||
const pageCache = require('../middleware/cache');
|
||||
pageCache.clear();
|
||||
|
||||
const post = contentService.getPost(postSlug);
|
||||
// Fire plugin lifecycle hook asynchronously
|
||||
pluginManager.triggerCommentCreated(newComment, post).catch(err => {
|
||||
console.error('[CommentRoute] Error in onCommentCreated plugin hook:', err);
|
||||
});
|
||||
|
||||
// If submitted via AJAX, return JSON
|
||||
if (req.xhr || req.headers.accept.includes('json')) {
|
||||
return res.json({
|
||||
success: true,
|
||||
comment: newComment,
|
||||
message: newComment.approved === 1 ? 'Comment posted!' : 'Your comment is awaiting moderation.'
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise redirect back to post comments anchor with flash message
|
||||
const msg = newComment.approved === 1 ? 'comment_posted' : 'comment_pending';
|
||||
res.redirect(`/${postSlug}?comment_status=${msg}#comments`);
|
||||
} catch (err) {
|
||||
console.error('[CommentRoute] Error saving comment:', err);
|
||||
res.status(500).json({ error: 'Failed to post comment' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,58 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Feed } = require('feed');
|
||||
const contentService = require('../services/content');
|
||||
|
||||
function generateFeed(site) {
|
||||
const feed = new Feed({
|
||||
title: site.title,
|
||||
description: site.tagline,
|
||||
id: site.url,
|
||||
link: site.url,
|
||||
language: 'en',
|
||||
image: `${site.url}/images/og-default.png`,
|
||||
favicon: `${site.url}/favicon.ico`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, ${site.author ? site.author.name : site.title}`,
|
||||
author: {
|
||||
name: site.author ? site.author.name : site.title,
|
||||
link: site.url
|
||||
}
|
||||
});
|
||||
|
||||
const posts = contentService.getAllPosts();
|
||||
|
||||
for (const post of posts) {
|
||||
const postUrl = `${site.url}/${post.slug}`;
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: postUrl,
|
||||
link: postUrl,
|
||||
description: post.excerpt,
|
||||
content: post.contentHtml,
|
||||
author: [
|
||||
{
|
||||
name: post.author,
|
||||
link: site.url
|
||||
}
|
||||
],
|
||||
date: post.date,
|
||||
image: post.cover ? (post.cover.startsWith('http') ? post.cover : `${site.url}${post.cover}`) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
router.get(['/feed', '/rss.xml', '/feed.xml'], (req, res) => {
|
||||
const feed = generateFeed(res.locals.site);
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(feed.rss2());
|
||||
});
|
||||
|
||||
router.get('/atom.xml', (req, res) => {
|
||||
const feed = generateFeed(res.locals.site);
|
||||
res.header('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
res.send(feed.atom1());
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const homePage = contentService.getPage('/');
|
||||
const recentPosts = contentService.getAllPosts().slice(0, 6);
|
||||
|
||||
res.render('pages/home', {
|
||||
title: homePage ? homePage.title : res.locals.site.title,
|
||||
page: homePage,
|
||||
recentPosts
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,28 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/:slug', (req, res, next) => {
|
||||
const pagePath = `/${req.params.slug}`;
|
||||
const page = contentService.getPage(pagePath);
|
||||
if (!page) {
|
||||
return next(); // Fall through to 404
|
||||
}
|
||||
|
||||
res.locals.seo = {
|
||||
title: `${page.title} - ${res.locals.site.title}`,
|
||||
description: page.frontmatter.description || res.locals.site.tagline,
|
||||
canonicalUrl: `${res.locals.site.url}${pagePath}`,
|
||||
ogImage: res.locals.seo.ogImage,
|
||||
type: 'website'
|
||||
};
|
||||
|
||||
const layout = page.layout === 'home' ? 'pages/home' : 'pages/page';
|
||||
|
||||
res.render(layout, {
|
||||
title: page.title,
|
||||
page
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
const commentService = require('../services/comments');
|
||||
|
||||
function renderPost(req, res, next, slug, lang) {
|
||||
const post = contentService.getPost(slug, lang);
|
||||
if (!post) {
|
||||
return next(); // Fall through to standalone pages or 404
|
||||
}
|
||||
|
||||
const { prev, next: nextPost } = contentService.getAdjacentPosts(slug);
|
||||
const comments = commentService.getCommentsForPost(slug);
|
||||
|
||||
// Set SEO metadata for post
|
||||
res.locals.seo = {
|
||||
title: `${post.title} - ${res.locals.site.title}`,
|
||||
description: post.excerpt || res.locals.site.tagline,
|
||||
canonicalUrl: `${res.locals.site.url}/${post.slug}${post.lang !== 'en' && !post.isDefault ? `/${post.lang}` : ''}`,
|
||||
ogImage: post.cover ? (post.cover.startsWith('http') ? post.cover : `${res.locals.site.url}${post.cover}`) : res.locals.seo.ogImage,
|
||||
type: 'article',
|
||||
publishedTime: post.date.toISOString(),
|
||||
author: post.author
|
||||
};
|
||||
|
||||
res.render('pages/post', {
|
||||
title: post.title,
|
||||
post,
|
||||
currentLang: lang || post.lang,
|
||||
comments,
|
||||
commentStatus: req.query.comment_status,
|
||||
prevPost: prev,
|
||||
nextPost
|
||||
});
|
||||
}
|
||||
|
||||
// Route for bilingual variant: /<post-slug>/<lang> (e.g. /my-post/zh)
|
||||
router.get('/:slug/:lang([a-zA-Z]{2,5})', (req, res, next) => {
|
||||
renderPost(req, res, next, req.params.slug, req.params.lang);
|
||||
});
|
||||
|
||||
// Route for default language: /<post-slug>
|
||||
router.get('/:slug', (req, res, next) => {
|
||||
// Exclude reserved top-level prefixes if any
|
||||
const reserved = ['blogs', 'search', 'quick-links', 'feed', 'rss.xml', 'sitemap.xml', 'robots.txt', 'api', 'plugins', 'public', 'posts-assets', 'guanli', 'admin'];
|
||||
if (reserved.includes(req.params.slug)) {
|
||||
return next();
|
||||
}
|
||||
renderPost(req, res, next, req.params.slug, null);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,104 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const searchEngine = require('../services/search-engine');
|
||||
const contentService = require('../services/content');
|
||||
const { loadSiteConfig } = require('../config');
|
||||
|
||||
function getRecommendedSearches() {
|
||||
const config = loadSiteConfig();
|
||||
const taxonomy = contentService.getTaxonomy();
|
||||
const dbService = require('../services/db');
|
||||
|
||||
const dynamicPills = [];
|
||||
|
||||
// 1. Add categories as discovery pills
|
||||
for (const cat of taxonomy.categories) {
|
||||
dynamicPills.push({
|
||||
label: `category:${cat.name.toLowerCase()}`,
|
||||
query: `category:${cat.name.toLowerCase()}`,
|
||||
count: cat.count
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Add top tags as discovery pills
|
||||
for (const tag of taxonomy.tags.slice(0, 6)) {
|
||||
dynamicPills.push({
|
||||
label: `tag:${tag.name.toLowerCase()}`,
|
||||
query: `tag:${tag.name.toLowerCase()}`,
|
||||
count: tag.count
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Add top trending searches from SQLite database
|
||||
const trendingDb = dbService.getTopSearchRecommendations(4);
|
||||
const customPills = [];
|
||||
for (const item of trendingDb) {
|
||||
if (!dynamicPills.some(p => p.query === item.query)) {
|
||||
customPills.push({
|
||||
label: item.label,
|
||||
query: item.query
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add any static curated pills from config
|
||||
const staticConfig = (config.search && config.search.recommended) || [];
|
||||
for (const item of staticConfig) {
|
||||
if (!dynamicPills.some(p => p.query === item.query) && !customPills.some(p => p.query === item.query)) {
|
||||
customPills.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dynamicPills,
|
||||
customPills
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/search', (req, res) => {
|
||||
const query = (req.query.q || '').trim();
|
||||
const recommended = getRecommendedSearches();
|
||||
|
||||
let searchData = {
|
||||
query: { filters: {}, phrases: [], keywords: [], raw: '' },
|
||||
results: [],
|
||||
total: 0
|
||||
};
|
||||
|
||||
if (query) {
|
||||
searchData = searchEngine.search(query);
|
||||
}
|
||||
|
||||
res.render('pages/search', {
|
||||
title: query ? `Search: ${query}` : 'Search',
|
||||
query,
|
||||
searchData,
|
||||
recommended
|
||||
});
|
||||
});
|
||||
|
||||
// JSON API endpoint for client-side search autocomplete / live search
|
||||
router.get('/api/search', (req, res) => {
|
||||
const query = (req.query.q || '').trim();
|
||||
if (!query) {
|
||||
return res.json({ query: null, results: [], total: 0 });
|
||||
}
|
||||
|
||||
const searchData = searchEngine.search(query);
|
||||
res.json({
|
||||
query: searchData.query,
|
||||
total: searchData.total,
|
||||
results: searchData.results.map(r => ({
|
||||
slug: r.post.slug,
|
||||
lang: r.post.lang,
|
||||
title: r.post.title,
|
||||
date: r.post.date,
|
||||
category: r.post.category,
|
||||
tags: r.post.tags,
|
||||
score: r.score,
|
||||
snippet: r.snippet
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,75 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/sitemap.xml', (req, res) => {
|
||||
const siteUrl = res.locals.site.url;
|
||||
const posts = contentService.getAllPosts();
|
||||
const pages = Array.from(contentService.pagesBySlug.values());
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
|
||||
|
||||
// Static / Top-level pages
|
||||
const staticRoutes = ['/', '/blogs', '/search'];
|
||||
for (const route of staticRoutes) {
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}${route}</loc>\n`;
|
||||
xml += ` <changefreq>daily</changefreq>\n`;
|
||||
xml += ` <priority>${route === '/' ? '1.0' : '0.8'}</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
|
||||
// Standalone pages
|
||||
for (const page of pages) {
|
||||
if (!page.slug || page.slug === '/') continue;
|
||||
const cleanSlug = page.slug.startsWith('/') ? page.slug : `/${page.slug}`;
|
||||
if (cleanSlug === '/guanli' || cleanSlug.startsWith('/guanli/') || cleanSlug === '/admin' || cleanSlug.startsWith('/admin/')) {
|
||||
continue;
|
||||
}
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}${cleanSlug}</loc>\n`;
|
||||
xml += ` <changefreq>weekly</changefreq>\n`;
|
||||
xml += ` <priority>0.7</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
|
||||
// Blog posts (including translations)
|
||||
for (const post of posts) {
|
||||
if (!post.slug || post.slug.startsWith('guanli') || post.slug.startsWith('admin')) continue;
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}/${post.slug}</loc>\n`;
|
||||
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
|
||||
xml += ` <changefreq>monthly</changefreq>\n`;
|
||||
xml += ` <priority>0.9</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
|
||||
// Bilingual URLs
|
||||
if (post.availableLanguages) {
|
||||
for (const lang of post.availableLanguages) {
|
||||
if (lang !== post.lang && lang !== 'default') {
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}/${post.slug}/${lang}</loc>\n`;
|
||||
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
|
||||
xml += ` <changefreq>monthly</changefreq>\n`;
|
||||
xml += ` <priority>0.8</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml += '</urlset>';
|
||||
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(xml);
|
||||
});
|
||||
|
||||
router.get('/robots.txt', (req, res) => {
|
||||
const siteUrl = res.locals.site.url;
|
||||
const content = `User-agent: *\nDisallow: /admin/\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||
res.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(content);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user