feat: added feed generation

This commit is contained in:
2026-02-19 22:30:04 +01:00
parent cfe5c37c5e
commit 7e593b587b
7 changed files with 758 additions and 293 deletions

View File

@@ -0,0 +1,430 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import * as crypto from 'crypto';
import { getDatabase } from '../database';
import { getPostEngine, type PostData } from './PostEngine';
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
const MAX_MAX_POSTS_PER_PAGE = 500;
export interface BlogGenerationOptions {
projectId: string;
projectName: string;
projectDescription?: string;
dataDir: string;
baseUrl: string;
maxPostsPerPage?: number;
}
export interface BlogGenerationResult {
path: string;
urlCount: number;
postCount: number;
feedPostCount: number;
tagCount: number;
categoryCount: number;
archiveCount: number;
feeds: {
rssPath: string;
atomPath: string;
};
changed: {
sitemap: boolean;
rss: boolean;
atom: boolean;
};
}
export function resolvePublicBaseUrl(publicUrl?: string): string | null {
const trimmed = (publicUrl || '').trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
} catch {
return null;
}
}
function clampMaxPostsPerPage(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_MAX_POSTS_PER_PAGE;
}
const normalized = Math.floor(value);
if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE;
if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE;
return normalized;
}
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
return `/${year}/${month}/${day}/${slug}`;
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
}
const parsed = new Date(post.createdAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function escapeXml(value: unknown): string {
const str = typeof value === 'string' ? value : value == null ? '' : String(value);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function buildSitemapUrl(
loc: string,
lastmod: string,
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
priority: string,
): string {
return [
' <url>',
` <loc>${escapeXml(loc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
' </url>',
].join('\n');
}
function splitParagraphs(markdown: string | null | undefined): string[] {
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
return normalizedMarkdown
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter((paragraph) => paragraph.length > 0);
}
function paragraphToXhtml(paragraph: string): string {
const escaped = escapeXml(paragraph).replace(/\n/g, '<br />');
return `<p>${escaped}</p>`;
}
function markdownToXhtml(markdown: string): string {
const paragraphs = splitParagraphs(markdown);
if (paragraphs.length === 0) {
return '<p></p>';
}
return paragraphs.map(paragraphToXhtml).join('');
}
function excerptToXhtml(post: PostData): string {
if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
return paragraphToXhtml(post.excerpt.trim());
}
const firstParagraph = splitParagraphs(post.content)[0] || '';
return paragraphToXhtml(firstParagraph);
}
function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>');
}
function computeContentHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
async function getHashSettingValue(key: string): Promise<string | null> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
const result = await client.execute({
sql: 'SELECT value FROM settings WHERE key = ? LIMIT 1',
args: [key],
});
if (!result.rows[0] || typeof result.rows[0].value !== 'string') {
return null;
}
return result.rows[0].value;
}
async function setHashSettingValue(key: string, value: string): Promise<void> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
await client.execute({
sql: 'INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
args: [key, value, new Date()],
});
}
async function writeFileIfHashChanged(filePath: string, content: string, hashKey: string): Promise<boolean> {
const hash = computeContentHash(content);
const previousHash = await getHashSettingValue(hashKey);
if (previousHash === hash) {
return false;
}
await fs.writeFile(filePath, content, 'utf-8');
await setHashSettingValue(hashKey, hash);
return true;
}
export class BlogGenerationEngine {
private readonly postEngine = getPostEngine();
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
onProgress(0, 'Loading posts...');
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
const publishedSnapshots = await Promise.all(
publishedCandidates.map(async (post) => {
const snapshot = await this.postEngine.getPublishedVersion(post.id);
return snapshot || post;
}),
);
const draftPublishedSnapshots = await Promise.all(
draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
);
const publishedPostById = new Map<string, PostData>();
for (const post of publishedSnapshots) {
publishedPostById.set(post.id, post);
}
for (const snapshot of draftPublishedSnapshots) {
if (snapshot) {
publishedPostById.set(snapshot.id, snapshot);
}
}
const publishedPosts = Array.from(publishedPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
onProgress(10, `Found ${publishedPosts.length} published posts`);
const now = new Date().toISOString();
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>();
const years = new Map<number, Date>();
const yearMonthDays = new Map<string, Date>();
const postUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) {
for (const tag of post.tags || []) allTags.add(tag);
for (const category of post.categories || []) allCategories.add(category);
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${options.baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt;
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const ymKey = `${year}/${month}`;
const ymdKey = `${year}/${month}/${day}`;
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
yearMonths.set(ymKey, updatedAt);
}
if (!years.has(year) || updatedAt > years.get(year)!) {
years.set(year, updatedAt);
}
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
yearMonthDays.set(ymdKey, updatedAt);
}
}
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
onProgress(40, 'Building sitemap XML...');
const urls: string[] = [];
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
for (const post of postUrls) {
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
}
onProgress(55, 'Adding archive pages...');
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
}
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
}
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
}
onProgress(70, 'Adding category pages...');
for (const category of Array.from(allCategories).sort()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
}
onProgress(80, 'Adding tag pages...');
for (const tag of Array.from(allTags).sort()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
}
onProgress(85, 'Building RSS and Atom feeds...');
const sitemapXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls,
'</urlset>',
'',
].join('\n');
const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date();
const baseLink = `${options.baseUrl}/`;
const feedTitle = options.projectName;
const feedDescription = options.projectDescription?.trim() || feedTitle;
const rssItems = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${options.baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const categories = [
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
];
return [
' <item>',
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(permalink)}</link>`,
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
...categories.map((entry) => ` ${entry}`),
' </item>',
].filter(Boolean).join('\n');
});
const rssXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
' <channel>',
` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`,
` <description>${escapeXml(feedDescription)}</description>`,
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
' <generator>bDS</generator>',
...rssItems,
' </channel>',
'</rss>',
'',
].join('\n');
const atomEntries = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${options.baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const categories = [
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
];
return [
' <entry>',
` <title>${escapeXml(post.title)}</title>`,
` <id>${escapeXml(permalink)}</id>`,
` <link href="${escapeXml(permalink)}" />`,
` <updated>${post.updatedAt.toISOString()}</updated>`,
` <published>${(post.publishedAt || post.updatedAt).toISOString()}</published>`,
post.author ? ` <author><name>${escapeXml(post.author)}</name></author>` : null,
` <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${excerptXhtml}</div></summary>`,
` <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${contentXhtml}</div></content>`,
...categories.map((entry) => ` ${entry}`),
' </entry>',
].filter(Boolean).join('\n');
});
const atomXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
` <title>${escapeXml(feedTitle)}</title>`,
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
` <id>${escapeXml(baseLink)}</id>`,
` <link href="${escapeXml(baseLink)}" rel="alternate" />`,
` <link href="${escapeXml(`${baseLink}atom.xml`)}" rel="self" />`,
` <updated>${feedUpdatedAt.toISOString()}</updated>`,
...atomEntries,
'</feed>',
'',
].join('\n');
onProgress(92, 'Writing sitemap and feeds...');
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
const rssPath = path.join(htmlDir, 'rss.xml');
const atomPath = path.join(htmlDir, 'atom.xml');
const hashKeyPrefix = `project:${options.projectId}:generation-hash`;
const [sitemapWritten, rssWritten, atomWritten] = await Promise.all([
writeFileIfHashChanged(sitemapPath, sitemapXml, `${hashKeyPrefix}:sitemap.xml`),
writeFileIfHashChanged(rssPath, rssXml, `${hashKeyPrefix}:rss.xml`),
writeFileIfHashChanged(atomPath, atomXml, `${hashKeyPrefix}:atom.xml`),
]);
onProgress(100, `Sitemap and feeds generated (${feedPosts.length} feed posts)`);
return {
path: sitemapPath,
urlCount: urls.length,
postCount: postUrls.length,
feedPostCount: feedPosts.length,
tagCount: allTags.size,
categoryCount: allCategories.size,
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
feeds: {
rssPath,
atomPath,
},
changed: {
sitemap: sitemapWritten,
rss: rssWritten,
atom: atomWritten,
},
};
}
}
let blogGenerationEngine: BlogGenerationEngine | null = null;
export function getBlogGenerationEngine(): BlogGenerationEngine {
if (!blogGenerationEngine) {
blogGenerationEngine = new BlogGenerationEngine();
}
return blogGenerationEngine;
}

View File

@@ -85,4 +85,11 @@ export {
type GitStatusFile,
type GitStatusCounts,
type GitInitResult,
} from './GitEngine';
} from './GitEngine';
export {
BlogGenerationEngine,
getBlogGenerationEngine,
resolvePublicBaseUrl,
type BlogGenerationOptions,
type BlogGenerationResult,
} from './BlogGenerationEngine';

View File

@@ -0,0 +1,59 @@
import { dialog } from 'electron';
import { getPostEngine } from '../engine/PostEngine';
import { getProjectEngine } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { taskManager } from '../engine/TaskManager';
import { getBlogGenerationEngine, resolvePublicBaseUrl } from '../engine/BlogGenerationEngine';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerBlogHandlers(safeHandle: SafeHandle): void {
safeHandle('blog:generateSitemap', async () => {
const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const blogGenerationEngine = getBlogGenerationEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Generate Sitemap',
execute: async (onProgress) => {
return blogGenerationEngine.generate({
projectId: project.id,
projectName: metadata?.name?.trim() || project.name,
projectDescription: metadata?.description,
dataDir,
baseUrl,
maxPostsPerPage: metadata?.maxPostsPerPage,
}, (progress, message) => onProgress(progress, message || ''));
},
});
});
}

View File

@@ -13,6 +13,8 @@ import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
import { media } from '../database/schema';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -86,50 +88,6 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function buildSitemapUrl(
loc: string,
lastmod: string,
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
priority: string,
): string {
return [
' <url>',
` <loc>${escapeXml(loc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
' </url>',
].join('\n');
}
function resolvePublicBaseUrl(publicUrl?: string): string | null {
const trimmed = (publicUrl || '').trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
} catch {
return null;
}
}
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -1263,252 +1221,8 @@ export function registerIpcHandlers(): void {
return engine.deleteDefinition(id);
});
// ============ Metadata Diff Handlers ============
safeHandle('metadataDiff:getStats', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.getTableStats();
});
safeHandle('metadataDiff:scan', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
// Forward progress events to renderer
const taskId = `metadata-diff-scan-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Scanning for metadata differences',
execute: async (onProgress) => {
return engine.scanAllPublishedPosts((current, total, message) => {
const percent = total > 0 ? (current / total) * 100 : 0;
onProgress(percent, message);
});
},
});
});
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncDbToFileTask(postIds, groupLabel);
});
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
// ============ Sitemap Generation ============
safeHandle('blog:generateSitemap', async () => {
const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Generate Sitemap',
execute: async (onProgress) => {
onProgress(0, 'Loading posts...');
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
const draftPublishedSnapshots = await Promise.all(
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
);
const publishedPostById = new Map<string, PostData>();
for (const post of publishedCandidates) {
publishedPostById.set(post.id, post);
}
for (const snapshot of draftPublishedSnapshots) {
if (snapshot) {
publishedPostById.set(snapshot.id, snapshot);
}
}
const publishedPosts = Array.from(publishedPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
onProgress(10, `Found ${publishedPosts.length} published posts`);
const now = new Date().toISOString();
// Collect all unique tags, categories, and year/month/day archives
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>(); // key -> most recent post date
const years = new Map<number, Date>(); // year -> most recent post date
const yearMonthDays = new Map<string, Date>(); // YYYY/MM/DD -> most recent post date
const postUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) {
const tags = post.tags || [];
const categories = post.categories || [];
for (const tag of tags) allTags.add(tag);
for (const cat of categories) allCategories.add(cat);
// Build canonical post URL using shared helpers
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt;
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
// Track archives
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const ymKey = `${year}/${month}`;
const ymdKey = `${year}/${month}/${day}`;
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
yearMonths.set(ymKey, updatedAt);
}
if (!years.has(year) || updatedAt > years.get(year)!) {
years.set(year, updatedAt);
}
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
yearMonthDays.set(ymdKey, updatedAt);
}
}
onProgress(40, 'Building sitemap XML...');
// Build XML sitemap
const urls: string[] = [];
// Homepage
urls.push(buildSitemapUrl(baseUrl + '/', now, 'daily', '1.0'));
// Individual posts
for (const post of postUrls) {
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
}
onProgress(55, 'Adding archive pages...');
// Year archives
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
}
// Year/Month archives
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
}
// Year/Month/Day archives
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
}
onProgress(70, 'Adding category pages...');
// Category pages
for (const category of Array.from(allCategories).sort()) {
urls.push(buildSitemapUrl(
`${baseUrl}/category/${encodeURIComponent(category)}`,
now,
'weekly',
'0.6',
));
}
onProgress(80, 'Adding tag pages...');
// Tag pages
for (const tag of Array.from(allTags).sort()) {
urls.push(buildSitemapUrl(
`${baseUrl}/tag/${encodeURIComponent(tag)}`,
now,
'weekly',
'0.6',
));
}
onProgress(90, 'Writing sitemap file...');
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls,
'</urlset>',
'',
].join('\n');
// Write to html folder in the project data directory
const htmlDir = path.join(dataDir, 'html');
await fsPromises.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
await fsPromises.writeFile(sitemapPath, xml, 'utf-8');
onProgress(100, `Sitemap generated with ${urls.length} URLs`);
return {
path: sitemapPath,
urlCount: urls.length,
postCount: postUrls.length,
tagCount: allTags.size,
categoryCount: allCategories.size,
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
};
},
});
});
registerMetadataDiffHandlers(safeHandle);
registerBlogHandlers(safeHandle);
// ============ Event Forwarding ============

View File

@@ -0,0 +1,61 @@
import { getProjectEngine } from '../engine/ProjectEngine';
import { taskManager } from '../engine/TaskManager';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
safeHandle('metadataDiff:getStats', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.getTableStats();
});
safeHandle('metadataDiff:scan', async () => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
const taskId = `metadata-diff-scan-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Scanning for metadata differences',
execute: async (onProgress) => {
return engine.scanAllPublishedPosts((current, total, message) => {
const percent = total > 0 ? (current / total) * 100 : 0;
onProgress(percent, message);
});
},
});
});
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncDbToFileTask(postIds, groupLabel);
});
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
const engine = getMetadataDiffEngine();
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
if (activeProject) {
engine.setProjectContext(activeProject.id);
}
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
}

View File

@@ -175,6 +175,8 @@ const mockTaskManager = {
off: vi.fn(),
};
const mockSettingsStore = new Map<string, string>();
const mockDatabase = {
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
@@ -185,6 +187,27 @@ const mockDatabase = {
})),
})),
})),
getLocalClient: vi.fn(() => ({
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
const key = String(args?.[0] ?? '');
return {
rows: mockSettingsStore.has(key)
? [{ value: mockSettingsStore.get(key) as string }]
: [],
};
}
if (sql.startsWith('INSERT INTO settings')) {
const key = String(args?.[0] ?? '');
const value = String(args?.[1] ?? '');
mockSettingsStore.set(key, value);
return { rowsAffected: 1 };
}
return { rows: [] };
}),
})),
getDataPaths: vi.fn(() => ({
database: '/mock/data/bds.db',
posts: '/mock/data/posts',
@@ -271,6 +294,7 @@ describe('IPC Handlers', () => {
// Clear all mocks
vi.clearAllMocks();
registeredHandlers.clear();
mockSettingsStore.clear();
resetMockCounters();
// Import and register handlers fresh for each test
@@ -1544,6 +1568,175 @@ describe('IPC Handlers', () => {
);
});
it('should generate rss and atom feeds with newest maxPostsPerPage published snapshots', async () => {
const mockProject = createMockProject({
id: 'test-project',
dataPath: '/mock/data',
});
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
description: 'Test Description',
publicUrl: 'https://blog.example.com',
maxPostsPerPage: 1,
});
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
if (filter.status === 'published') {
return [
{
id: 'post-new',
projectId: 'test-project',
title: 'Newest <Post>',
slug: 'newest-post',
excerpt: '',
content: '',
status: 'published',
createdAt: new Date('2024-03-05T10:00:00Z'),
updatedAt: new Date('2024-03-05T11:00:00Z'),
publishedAt: new Date('2024-03-05T10:00:00Z'),
tags: ['tag-one'],
categories: ['category-one'],
},
{
id: 'post-old',
projectId: 'test-project',
title: 'Old Post',
slug: 'old-post',
excerpt: '',
content: '',
status: 'published',
createdAt: new Date('2024-02-01T10:00:00Z'),
updatedAt: new Date('2024-02-01T11:00:00Z'),
publishedAt: new Date('2024-02-01T10:00:00Z'),
tags: ['tag-two'],
categories: ['category-two'],
},
];
}
if (filter.status === 'draft') {
return [];
}
return [];
});
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
if (id !== 'post-new') return null;
return {
id: 'post-new',
projectId: 'test-project',
title: 'Newest <Post>',
slug: 'newest-post',
excerpt: undefined,
content: 'First paragraph with <tag> & symbol.\n\nSecond paragraph.',
status: 'published',
author: 'Author A',
createdAt: new Date('2024-03-05T10:00:00Z'),
updatedAt: new Date('2024-03-05T11:00:00Z'),
publishedAt: new Date('2024-03-05T10:00:00Z'),
tags: ['tag-one'],
categories: ['category-one'],
};
});
const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
mockTaskManager.runTask.mockImplementation(async (task: any) => {
const onProgress = vi.fn();
return await task.execute(onProgress);
});
await invokeHandler('blog:generateSitemap');
const writtenFiles = vi.mocked(writeFile).mock.calls.map(([filePath, body]) => ({
filePath: filePath as string,
body: body as string,
}));
const rss = writtenFiles.find((entry) => entry.filePath.endsWith('/rss.xml'))?.body;
const atom = writtenFiles.find((entry) => entry.filePath.endsWith('/atom.xml'))?.body;
expect(rss).toBeTruthy();
expect(atom).toBeTruthy();
expect(rss).toContain('newest-post');
expect(rss).not.toContain('old-post');
expect(atom).toContain('newest-post');
expect(atom).not.toContain('old-post');
expect(rss).toContain('Newest &lt;Post&gt;');
expect(rss).toContain('First paragraph with &lt;tag&gt; &amp; symbol.');
expect(atom).toContain('<category term="tag-one"');
expect(atom).toContain('<category term="category-one"');
});
it('should skip rewriting sitemap and feeds when content hash is unchanged', async () => {
const mockProject = createMockProject({
id: 'test-project',
dataPath: '/mock/data',
});
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
publicUrl: 'https://blog.example.com',
maxPostsPerPage: 5,
});
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
if (filter.status === 'published') {
return [
{
id: 'post-1',
projectId: 'test-project',
title: 'Hash test',
slug: 'hash-test',
excerpt: 'Hash excerpt',
content: '',
status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'),
publishedAt: new Date('2024-01-15T10:00:00Z'),
tags: [],
categories: [],
},
];
}
if (filter.status === 'draft') {
return [];
}
return [];
});
mockPostEngine.getPublishedVersion.mockImplementation(async () => ({
id: 'post-1',
projectId: 'test-project',
title: 'Hash test',
slug: 'hash-test',
excerpt: 'Hash excerpt',
content: 'Hash content',
status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'),
publishedAt: new Date('2024-01-15T10:00:00Z'),
tags: [],
categories: [],
}));
const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
mockTaskManager.runTask.mockImplementation(async (task: any) => {
const onProgress = vi.fn();
return await task.execute(onProgress);
});
await invokeHandler('blog:generateSitemap');
vi.mocked(writeFile).mockClear();
await invokeHandler('blog:generateSitemap');
expect(writeFile).not.toHaveBeenCalled();
});
it('should throw error when no active project', async () => {
mockProjectEngine.getActiveProject.mockResolvedValue(null);

View File

@@ -9,10 +9,11 @@ describe('documentation structure and presentation', () => {
const docPath = path.resolve(process.cwd(), 'DOCUMENTATION.md');
const markdown = readFileSync(docPath, 'utf8');
expect(markdown).toContain('## Index');
expect(markdown).toContain('## In this article');
expect(markdown).not.toMatch(/^\s{2,}-\s+\[/m);
expect(markdown).not.toMatch(/^##\s+\d+\)/m);
expect(markdown).toMatch(/^###\s+1\)/m);
expect(markdown).toContain('[Who this guide is for](#who-this-guide-is-for)');
expect(markdown).toContain('## Who this guide is for');
});
it('scopes Pico conditional styling to the documentation view', () => {