From a0a7f491354c34bad5a00415f593e74ba1fdda0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 20:17:05 +0000 Subject: [PATCH] feat: add sitemap generator to Blog menu Add a "Generate Sitemap" function to the Blog menu that generates a standard XML sitemap in the project's html/ folder. The sitemap includes entries for all published posts, archive pages (year, month, day), category pages, and tag pages using the preview server URL structure. Runs as a background task with progress tracking via the task manager. https://claude.ai/code/session_01PdJyxeeNGf4Bkxvq86GVaZ --- src/main/ipc/handlers.ts | 194 ++++++++++++++++++++++++++++++++ src/main/main.ts | 1 + src/main/preload.ts | 5 + src/main/shared/electronApi.ts | 10 ++ src/main/shared/menuCommands.ts | 3 + src/renderer/App.tsx | 11 ++ 6 files changed, 224 insertions(+) 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) => {