diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts
index 11ece84..fb18454 100644
--- a/src/main/ipc/handlers.ts
+++ b/src/main/ipc/handlers.ts
@@ -86,6 +86,31 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function buildSitemapUrl(
+ loc: string,
+ lastmod: string,
+ changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
+ priority: string,
+): string {
+ return [
+ ' ',
+ ` ${escapeXml(loc)}`,
+ ` ${escapeXml(lastmod)}`,
+ ` ${changefreq}`,
+ ` ${priority}`,
+ ' ',
+ ].join('\n');
+}
+
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -1277,6 +1302,175 @@ export function registerIpcHandlers(): void {
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
});
+ // ============ Sitemap Generation ============
+
+ safeHandle('blog:generateSitemap', async () => {
+ const projectEngine = getProjectEngine();
+ const project = await projectEngine.getActiveProject();
+ if (!project) {
+ throw new Error('No active project');
+ }
+
+ const postEngine = getPostEngine();
+ const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
+ postEngine.setProjectContext(project.id, dataDir);
+
+ const metaEngine = getMetaEngine();
+ metaEngine.setProjectContext(project.id, project.dataPath);
+
+ const taskId = `sitemap-generate-${Date.now()}`;
+
+ return taskManager.runTask({
+ id: taskId,
+ name: 'Generate Sitemap',
+ execute: async (onProgress) => {
+ onProgress(0, 'Loading posts...');
+
+ const db = getDatabase().getLocal();
+ const { posts: postsTable } = await import('../database/schema');
+ const { eq: eqOp, desc: descOp } = await import('drizzle-orm');
+
+ const dbPosts = await db
+ .select()
+ .from(postsTable)
+ .where(eqOp(postsTable.projectId, project.id))
+ .orderBy(descOp(postsTable.createdAt))
+ .all();
+
+ // Only include published and archived posts (not drafts) in sitemap
+ const publishedPosts = dbPosts.filter(p => p.status === 'published' || p.status === 'archived');
+
+ onProgress(10, `Found ${publishedPosts.length} published posts`);
+
+ const baseUrl = 'http://127.0.0.1:4123';
+ const now = new Date().toISOString();
+
+ // Collect all unique tags, categories, and year/month/day archives
+ const allTags = new Set();
+ const allCategories = new Set();
+ const yearMonths = new Map(); // key -> most recent post date
+ const years = new Map(); // year -> most recent post date
+ const yearMonthDays = new Map(); // YYYY/MM/DD -> most recent post date
+
+ const postUrls: Array<{ loc: string; lastmod: string }> = [];
+
+ for (const post of publishedPosts) {
+ // Parse tags and categories
+ const tags: string[] = JSON.parse(post.tags || '[]');
+ const categories: string[] = JSON.parse(post.categories || '[]');
+
+ for (const tag of tags) allTags.add(tag);
+ for (const cat of categories) allCategories.add(cat);
+
+ // Build post URL: /:YYYY/:MM/:DD/:slug
+ const createdAt = post.createdAt instanceof Date ? post.createdAt : new Date(post.createdAt as unknown as number);
+ const year = createdAt.getFullYear();
+ const month = String(createdAt.getMonth() + 1).padStart(2, '0');
+ const day = String(createdAt.getDate()).padStart(2, '0');
+
+ const postUrl = `${baseUrl}/${year}/${month}/${day}/${post.slug}`;
+ const updatedAt = post.updatedAt instanceof Date ? post.updatedAt : new Date(post.updatedAt as unknown as number);
+ postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
+
+ // Track archives
+ 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 = [
+ '',
+ '',
+ ...urls,
+ '',
+ '',
+ ].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,
+ };
+ },
+ });
+ });
+
// ============ Event Forwarding ============
// Forward engine events to renderer
diff --git a/src/main/main.ts b/src/main/main.ts
index f5dd29e..a940ba5 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -318,6 +318,7 @@ function createApplicationMenu(): Menu {
buildSharedMenuItem('reindexText'),
{ type: 'separator' },
buildSharedMenuItem('metadataDiff'),
+ buildSharedMenuItem('generateSitemap'),
],
},
{
diff --git a/src/main/preload.ts b/src/main/preload.ts
index f684544..962c96c 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -249,6 +249,11 @@ export const electronAPI: ElectronAPI = {
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncFileToDb', postIds, field, groupLabel),
},
+ // Blog operations
+ blog: {
+ generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
+ },
+
// AI Chat (OpenCode Zen API integration)
chat: {
// API Key Management
diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts
index 4e0b82f..fc1564d 100644
--- a/src/main/shared/electronApi.ts
+++ b/src/main/shared/electronApi.ts
@@ -589,6 +589,16 @@ export interface ElectronAPI {
syncDbToFile: (postIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
};
+ blog: {
+ generateSitemap: () => Promise<{
+ path: string;
+ urlCount: number;
+ postCount: number;
+ tagCount: number;
+ categoryCount: number;
+ archiveCount: number;
+ }>;
+ };
chat: {
// API Key Management
checkReady: () => Promise;
diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts
index ec05d22..e67c29f 100644
--- a/src/main/shared/menuCommands.ts
+++ b/src/main/shared/menuCommands.ts
@@ -21,6 +21,7 @@ export type AppMenuAction =
| 'rebuildDatabase'
| 'reindexText'
| 'metadataDiff'
+ | 'generateSitemap'
| 'about'
| 'viewOnGitHub'
| 'reportIssue';
@@ -85,6 +86,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'Rebuild Database from Files', action: 'rebuildDatabase' },
{ label: 'Reindex Search Text', action: 'reindexText' },
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
+ { label: 'Generate Sitemap', action: 'generateSitemap' },
],
},
{
@@ -113,6 +115,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> =
rebuildDatabase: 'menu:rebuildDatabase',
reindexText: 'menu:reindexText',
metadataDiff: 'menu:metadataDiff',
+ generateSitemap: 'menu:generateSitemap',
about: 'menu:about',
};
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index b54c315..1ce1bac 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -277,6 +277,17 @@ const App: React.FC = () => {
}) || (() => {})
);
+ unsubscribers.push(
+ window.electronAPI?.on('menu:generateSitemap', async () => {
+ try {
+ await window.electronAPI?.blog.generateSitemap();
+ } catch (error) {
+ console.error('Sitemap generation failed:', error);
+ showToast.error('Sitemap generation failed');
+ }
+ }) || (() => {})
+ );
+
// Import completion event - refresh posts and media stores
unsubscribers.push(
window.electronAPI?.import.onComplete(async (data) => {