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:
2026-09-16 23:08:53 -07:00
parent ac2c4c09ef
commit 8c0b04db0e
126 changed files with 8515 additions and 3984 deletions
+243
View File
@@ -0,0 +1,243 @@
class SearchEngine {
constructor() {
this.posts = [];
}
setPosts(posts) {
this.posts = posts;
}
// Parse a query like: tag:k8s "container image" category:tech cgroup
parseQuery(queryString) {
if (!queryString || typeof queryString !== 'string') {
return { filters: {}, phrases: [], keywords: [], raw: '' };
}
const raw = queryString.trim();
const filters = {
tags: [],
categories: [],
authors: [],
langs: []
};
const phrases = [];
const keywords = [];
// Regex to match: field:value, field:"quoted value", "quoted phrase", or bare words
const tokenRegex = /(?:([a-zA-Z_-]+):(?:"([^"]+)"|([^\s]+)))|(?:"([^"]+)")|([^\s]+)/g;
let match;
while ((match = tokenRegex.exec(raw)) !== null) {
if (match[1]) {
// Schema filter (e.g. tag:k8s, category:tech)
const field = match[1].toLowerCase();
const value = (match[2] || match[3]).trim().toLowerCase();
if (field === 'tag' || field === 'tags') {
filters.tags.push(value);
} else if (field === 'category' || field === 'cat') {
filters.categories.push(value);
} else if (field === 'author') {
filters.authors.push(value);
} else if (field === 'lang') {
filters.langs.push(value);
} else {
// Unknown field, treat as keyword
keywords.push(`${field}:${value}`);
}
} else if (match[4]) {
// Quoted exact phrase
phrases.push(match[4].trim());
} else if (match[5]) {
// Bare keyword
keywords.push(match[5].trim());
}
}
return { filters, phrases, keywords, raw };
}
search(queryString) {
const parsed = this.parseQuery(queryString);
if (!parsed.raw) {
return { query: parsed, results: [], total: 0 };
}
const results = [];
for (const post of this.posts) {
let matches = true;
let score = 0;
// 1. Schema Filter Gate: Category
if (parsed.filters.categories.length > 0) {
const postCat = (post.category || '').toLowerCase();
const catMatch = parsed.filters.categories.every(cat => postCat === cat);
if (!catMatch) matches = false;
}
// 2. Schema Filter Gate: Tags
if (matches && parsed.filters.tags.length > 0) {
const postTags = (post.tags || []).map(t => String(t).toLowerCase());
const tagsMatch = parsed.filters.tags.every(reqTag =>
postTags.some(t => t === reqTag || t.replace(/[^a-z0-9]/g, '') === reqTag.replace(/[^a-z0-9]/g, ''))
);
if (!tagsMatch) matches = false;
}
// 3. Schema Filter Gate: Author
if (matches && parsed.filters.authors.length > 0) {
const postAuthor = (post.author || '').toLowerCase();
const authorMatch = parsed.filters.authors.every(a => postAuthor.includes(a));
if (!authorMatch) matches = false;
}
// 4. Schema Filter Gate: Lang
if (matches && parsed.filters.langs.length > 0) {
const postLang = (post.lang || 'en').toLowerCase();
const availableLangs = (post.availableLanguages || [postLang]).map(l => l.toLowerCase());
const langMatch = parsed.filters.langs.every(l => availableLangs.includes(l));
if (!langMatch) matches = false;
}
if (!matches) continue;
// Searchable text fields
const title = post.title || '';
const excerpt = post.excerpt || '';
const content = post.rawContent || '';
const tagsStr = (post.tags || []).join(' ');
const fullHaystack = `${title}\n${tagsStr}\n${excerpt}\n${content}`.toLowerCase();
// 5. Exact Phrase Matching (must match ALL quoted phrases)
let phraseMatches = true;
for (const phrase of parsed.phrases) {
const lowerPhrase = phrase.toLowerCase();
if (!fullHaystack.includes(lowerPhrase)) {
phraseMatches = false;
break;
}
// Score bonus for phrase
if (title.toLowerCase().includes(lowerPhrase)) score += 30;
if (excerpt.toLowerCase().includes(lowerPhrase)) score += 15;
if (content.toLowerCase().includes(lowerPhrase)) score += 10;
}
if (!phraseMatches) continue;
// 6. Bare Keywords Matching (must match ALL keywords)
let keywordMatches = true;
for (const kw of parsed.keywords) {
const lowerKw = kw.toLowerCase();
if (!fullHaystack.includes(lowerKw)) {
keywordMatches = false;
break;
}
if (title.toLowerCase().includes(lowerKw)) score += 15;
if (tagsStr.toLowerCase().includes(lowerKw)) score += 10;
if (excerpt.toLowerCase().includes(lowerKw)) score += 5;
if (content.toLowerCase().includes(lowerKw)) score += 2;
}
if (!keywordMatches) continue;
// Recency score bonus (up to 5 points)
if (post.date) {
const ageInDays = (Date.now() - new Date(post.date).getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays > 0) {
score += Math.max(0, 5 - Math.log10(ageInDays));
}
}
// Generate snippet with highlighted terms
const snippet = this.generateSnippet(post, parsed);
results.push({
post,
score,
snippet
});
}
// Deduplicate by post.slug so bilingual variants don't produce duplicate cards
const uniqueResultsMap = new Map();
for (const res of results) {
const existing = uniqueResultsMap.get(res.post.slug);
if (!existing || res.score > existing.score) {
uniqueResultsMap.set(res.post.slug, res);
}
}
const deduplicatedResults = Array.from(uniqueResultsMap.values());
// Sort by relevance score descending
deduplicatedResults.sort((a, b) => b.score - a.score);
// Log query to SQLite for trending recommendations & analytics
try {
const dbService = require('./db');
dbService.logSearch(queryString, deduplicatedResults.length);
} catch (e) {}
return {
query: parsed,
results: deduplicatedResults,
total: deduplicatedResults.length
};
}
generateSnippet(post, parsed) {
const text = post.rawContent || post.excerpt || '';
const searchTerms = [...parsed.phrases, ...parsed.keywords];
if (searchTerms.length === 0) {
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
}
// Find position of first matching term
let bestIndex = -1;
let matchedTerm = '';
const lowerText = text.toLowerCase();
for (const term of searchTerms) {
const idx = lowerText.indexOf(term.toLowerCase());
if (idx !== -1 && (bestIndex === -1 || idx < bestIndex)) {
bestIndex = idx;
matchedTerm = term;
}
}
if (bestIndex === -1) {
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
}
// Extract snippet around bestIndex
const start = Math.max(0, bestIndex - 60);
const end = Math.min(text.length, bestIndex + 100);
let snippet = text.slice(start, end).replace(/\s+/g, ' ');
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
// Highlight terms
let safeSnippet = this.escapeHtml(snippet);
for (const term of searchTerms) {
if (!term) continue;
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
safeSnippet = safeSnippet.replace(regex, '<mark>$1</mark>');
}
return safeSnippet;
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
module.exports = new SearchEngine();