diff --git a/package-lock.json b/package-lock.json index 6d00506..ff7384b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,7 +171,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -745,7 +744,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -822,7 +820,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -844,7 +841,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -940,7 +936,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -981,7 +976,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1376,6 +1370,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1397,6 +1392,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1413,6 +1409,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1427,6 +1424,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3775,7 +3773,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz", "integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.17.0", "@libsql/hrana-client": "^0.9.0", @@ -4964,7 +4961,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5186,7 +5184,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5197,7 +5194,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5415,7 +5411,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -5612,7 +5607,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6135,7 +6129,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6821,7 +6814,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6958,8 +6952,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -7215,7 +7208,6 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7297,7 +7289,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.1", @@ -7770,6 +7763,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7790,6 +7784,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7923,7 +7918,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8836,7 +8830,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9143,7 +9137,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9435,6 +9428,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10675,6 +10669,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10687,7 +10682,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -11185,7 +11179,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11261,6 +11254,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11278,6 +11272,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11288,6 +11283,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11303,6 +11299,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -11457,7 +11454,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11491,7 +11487,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11525,7 +11520,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11603,7 +11597,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11613,7 +11606,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11643,7 +11635,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11905,6 +11898,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12017,7 +12011,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -12622,6 +12616,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12888,8 +12883,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -13426,7 +13420,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13707,7 +13700,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14267,7 +14259,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -14345,7 +14336,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 21e953e..623aed3 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -18,6 +18,7 @@ export interface ProjectMetadata { name: string; description?: string; dataPath?: string; // Custom path for project data + publicUrl?: string; // Public base URL for the published blog (e.g., https://example.com) mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) @@ -48,10 +49,21 @@ function sanitizeMaxPostsPerPage(value: unknown): number | undefined { return rounded; } +function sanitizePublicUrl(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const trimmed = String(value).trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); + const publicUrl = sanitizePublicUrl(metadata.publicUrl); return { ...metadata, + publicUrl, maxPostsPerPage, }; } @@ -173,6 +185,7 @@ export class MetaEngine extends EventEmitter { name: normalizedUpdates.name || '', description: normalizedUpdates.description, dataPath: normalizedUpdates.dataPath, + publicUrl: normalizedUpdates.publicUrl, mainLanguage: normalizedUpdates.mainLanguage, defaultAuthor: normalizedUpdates.defaultAuthor, maxPostsPerPage: normalizedUpdates.maxPostsPerPage, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 11ece84..5ce95c2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -86,6 +86,50 @@ 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'); +} + +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 ============ @@ -724,6 +768,7 @@ export function registerIpcHandlers(): void { return { name: metadata.name || undefined, description: metadata.description || undefined, + publicUrl: metadata.publicUrl || undefined, mainLanguage: metadata.mainLanguage || undefined, }; } catch { @@ -822,7 +867,7 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { const engine = getMetaEngine(); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); @@ -1277,6 +1322,194 @@ 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 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(); + 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(); + 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) { + 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 = [ + '', + '', + ...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..ab5110f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -38,6 +38,7 @@ export interface ProjectMetadata { name: string; description?: string; dataPath?: string; + publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; @@ -510,7 +511,7 @@ export interface ElectronAPI { showItemInFolder: (itemPath: string) => Promise; selectFolder: (title?: string) => Promise; getDefaultProjectPath: (projectId: string) => Promise; - readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; + readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; setPreviewPostTarget: (postId: string | null) => Promise; triggerMenuAction: (action: string) => Promise; }; @@ -524,7 +525,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise; }; tags: { getAll: () => Promise; @@ -589,6 +590,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) => { diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 03f52a6..0ecbc3c 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -107,6 +107,7 @@ export const SettingsView: React.FC = () => { const [projectName, setProjectName] = useState(''); const [projectDescription, setProjectDescription] = useState(''); const [projectDataPath, setProjectDataPath] = useState(''); + const [projectPublicUrl, setProjectPublicUrl] = useState(''); const [defaultProjectPath, setDefaultProjectPath] = useState(''); const [projectMainLanguage, setProjectMainLanguage] = useState('en'); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); @@ -145,8 +146,13 @@ export const SettingsView: React.FC = () => { setDefaultProjectPath(path); }); - // Load project metadata (includes mainLanguage and defaultAuthor) + // Load project metadata (includes public URL, language, and default author) window.electronAPI?.meta.getProjectMetadata().then(metadata => { + if (metadata?.publicUrl) { + setProjectPublicUrl(metadata.publicUrl); + } else { + setProjectPublicUrl(''); + } if (metadata?.mainLanguage) { setProjectMainLanguage(metadata.mainLanguage); } @@ -256,6 +262,7 @@ export const SettingsView: React.FC = () => { name: projectName.trim() || activeProject.name, description: projectDescription.trim(), dataPath: projectDataPath.trim() || undefined, + publicUrl: projectPublicUrl.trim() || undefined, mainLanguage: projectMainLanguage, defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), @@ -280,7 +287,7 @@ export const SettingsView: React.FC = () => { }; // Keywords for each section for search filtering - const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; + const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; @@ -346,6 +353,20 @@ export const SettingsView: React.FC = () => { + + setProjectPublicUrl(e.target.value)} + /> + + { expect(metadata?.maxPostsPerPage).toBe(42); }); + it('should set and get publicUrl in project metadata', async () => { + await metaEngine.setProjectMetadata({ + name: 'My Blog', + publicUrl: 'https://example.com/blog', + }); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.publicUrl).toBe('https://example.com/blog'); + }); + + it('should persist publicUrl to filesystem', async () => { + await metaEngine.setProjectMetadata({ + name: 'Test Project', + publicUrl: 'https://example.com', + }); + + const metaDir = metaEngine.getMetaDir(); + const projectPath = normalizePath(`${metaDir}/project.json`); + + const content = mockFiles.get(projectPath); + const parsed = JSON.parse(content!); + expect(parsed.publicUrl).toBe('https://example.com'); + }); + it('should sanitize invalid maxPostsPerPage values from filesystem', async () => { const metaDir = metaEngine.getMetaDir(); const projectPath = normalizePath(`${metaDir}/project.json`); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 3255f8d..039a250 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -30,6 +30,7 @@ vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), + showMessageBox: vi.fn(), }, shell: { openPath: vi.fn(), @@ -52,6 +53,7 @@ const mockPostEngine = { publishPost: vi.fn(), discardChanges: vi.fn(), hasPublishedVersion: vi.fn(), + getPublishedVersion: vi.fn(), isSlugAvailable: vi.fn(), generateUniqueSlug: vi.fn(), rebuildDatabaseFromFiles: vi.fn(), @@ -168,6 +170,7 @@ const mockGitEngine = { const mockTaskManager = { getAllTasks: vi.fn(), cancelTask: vi.fn(), + runTask: vi.fn(), on: vi.fn(), off: vi.fn(), }; @@ -1437,6 +1440,436 @@ describe('IPC Handlers', () => { }); }); + // ============ Blog Handlers ============ + describe('Blog Handlers', () => { + describe('blog:generateSitemap', () => { + it('should call taskManager.runTask with sitemap generation task', 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', + }); + + // Mock post engine to return published posts and drafts + const mockPublishedPosts = [ + { + id: 'post-1', + projectId: 'test-project', + slug: 'test-post', + status: 'published', + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-20T15:00:00Z'), + tags: ['tag1', 'tag2'], + categories: ['category1'], + }, + { + id: 'post-2', + projectId: 'test-project', + slug: 'another-post', + status: 'published', + createdAt: new Date('2024-02-10T12:00:00Z'), + updatedAt: new Date('2024-02-12T09:00:00Z'), + tags: ['tag2', 'tag3'], + categories: ['category2'], + }, + ]; + + const mockDraftPosts = [ + { + id: 'post-3', + projectId: 'test-project', + slug: 'draft-post', + status: 'draft', + createdAt: new Date('2024-03-01T08:00:00Z'), + updatedAt: new Date('2024-03-01T08:00:00Z'), + tags: [], + categories: [], + }, + ]; + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return mockDraftPosts; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + // Mock fs.writeFile + const { writeFile, mkdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + // Mock taskManager.runTask to execute the task immediately + mockTaskManager.runTask.mockImplementation(async (task: any) => { + const onProgress = vi.fn(); + return await task.execute(onProgress); + }); + + const result = await invokeHandler('blog:generateSitemap'); + + // Verify taskManager.runTask was called + expect(mockTaskManager.runTask).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^sitemap-generate-\d+$/), + name: 'Generate Sitemap', + execute: expect.any(Function), + }) + ); + + // Verify result contains expected data + expect(result).toEqual( + expect.objectContaining({ + path: expect.stringContaining('sitemap.xml'), + postCount: 2, // Only published posts, not drafts + tagCount: 3, // tag1, tag2, tag3 + categoryCount: 2, // category1, category2 + }) + ); + + // Verify fs operations + expect(mkdir).toHaveBeenCalledWith('/mock/data/dir/html', { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('sitemap.xml'), + expect.stringContaining(''), + 'utf-8' + ); + }); + + it('should throw error when no active project', async () => { + mockProjectEngine.getActiveProject.mockResolvedValue(null); + + await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('No active project'); + + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should filter out draft and archived posts from sitemap', 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', + }); + + const mockPublishedPosts = [ + { + id: 'post-1', + projectId: 'test-project', + slug: 'published-post', + status: 'published', + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-20T15:00:00Z'), + tags: [], + categories: [], + }, + ]; + + const mockDraftPosts = [ + { + id: 'post-2', + projectId: 'test-project', + slug: 'draft-post', + status: 'draft', + createdAt: new Date('2024-02-10T12:00:00Z'), + updatedAt: new Date('2024-02-12T09:00:00Z'), + tags: [], + categories: [], + }, + ]; + + const mockArchivedPosts = [ + { + id: 'post-3', + projectId: 'test-project', + slug: 'archived-post', + status: 'archived', + createdAt: new Date('2024-03-01T08:00:00Z'), + updatedAt: new Date('2024-03-01T08:00:00Z'), + tags: [], + categories: [], + }, + ]; + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return mockDraftPosts; + } + if (filter.status === 'archived') { + return mockArchivedPosts; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + 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); + }); + + const result = await invokeHandler('blog:generateSitemap'); + + // Verify only published posts are included + expect(result.postCount).toBe(1); + + // Verify the sitemap XML only contains the published post + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + expect(sitemapXml).toContain('published-post'); + expect(sitemapXml).not.toContain('draft-post'); + expect(sitemapXml).not.toContain('archived-post'); + }); + + it('should include published snapshot for drafts with a former published version', 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', + }); + + const publishedPost = { + id: 'post-published', + projectId: 'test-project', + slug: 'published-post', + status: 'published', + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-20T15:00:00Z'), + tags: [], + categories: [], + }; + + const neverPublishedDraft = { + id: 'post-draft-new', + projectId: 'test-project', + slug: 'draft-no-published-version', + status: 'draft', + createdAt: new Date('2024-02-10T12:00:00Z'), + updatedAt: new Date('2024-02-12T09:00:00Z'), + tags: [], + categories: [], + }; + + const draftWithPublishedVersion = { + id: 'post-draft-with-published', + projectId: 'test-project', + slug: 'draft-current-slug', + status: 'draft', + createdAt: new Date('2024-03-01T08:00:00Z'), + updatedAt: new Date('2024-03-03T08:00:00Z'), + tags: [], + categories: [], + }; + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return [publishedPost]; + } + if (filter.status === 'draft') { + return [neverPublishedDraft, draftWithPublishedVersion]; + } + return []; + }); + + mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => { + if (id !== 'post-draft-with-published') { + return null; + } + + return { + id, + projectId: 'test-project', + slug: 'published-snapshot-slug', + status: 'published', + createdAt: new Date('2023-10-05T07:00:00Z'), + updatedAt: new Date('2023-10-20T09: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); + }); + + const result = await invokeHandler('blog:generateSitemap'); + + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'published' }); + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'draft' }); + expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-new'); + expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-with-published'); + + expect(result.postCount).toBe(2); + + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + expect(sitemapXml).toContain('published-post'); + expect(sitemapXml).toContain('published-snapshot-slug'); + expect(sitemapXml).not.toContain('draft-no-published-version'); + expect(sitemapXml).not.toContain('draft-current-slug'); + }); + + it('should use canonical path helpers for post URLs', 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', + }); + + const mockPublishedPosts = [ + { + id: 'post-1', + projectId: 'test-project', + slug: 'my-test-post', + status: 'published', + createdAt: new Date('2024-03-25T10:00:00Z'), + updatedAt: new Date('2024-03-26T15:00:00Z'), + tags: [], + categories: [], + }, + ]; + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return []; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + 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 writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + // Verify canonical URL format: /YYYY/MM/DD/slug + expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/my-test-post'); + }); + + it('should show setup dialog and abort when project public URL is missing', 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', + }); + + const { dialog } = await import('electron'); + + await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('Project public URL is not configured'); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + title: 'Public URL Required', + }), + ); + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should use project public URL from metadata as sitemap base URL', 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/', + }); + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return [ + { + id: 'post-1', + projectId: 'test-project', + slug: 'public-url-test-post', + status: 'published', + createdAt: new Date('2024-03-25T10:00:00Z'), + updatedAt: new Date('2024-03-26T15:00:00Z'), + tags: [], + categories: [], + }, + ]; + } + if (filter.status === 'draft') { + return []; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + 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 writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/public-url-test-post'); + expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post'); + }); + }); + }); + // ============ Error Handling ============ describe('Error Handling', () => { it('should silently handle "Database is closing" errors', async () => { diff --git a/tests/renderer/components/SettingsView.test.tsx b/tests/renderer/components/SettingsView.test.tsx index 154a8ed..a9d42b9 100644 --- a/tests/renderer/components/SettingsView.test.tsx +++ b/tests/renderer/components/SettingsView.test.tsx @@ -43,8 +43,8 @@ describe('SettingsView Diff Preferences', () => { meta: { ...(window as any).electronAPI?.meta, getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), - getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75 }), - updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12 }), + getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com' }), + updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }), }, chat: { ...(window as any).electronAPI?.chat, @@ -92,4 +92,19 @@ describe('SettingsView Diff Preferences', () => { expect.objectContaining({ maxPostsPerPage: 75 }) ); }); + + it('includes project public URL in metadata save payload', async () => { + render(); + + await screen.findByDisplayValue('https://example.com'); + + const saveButton = screen.getByRole('button', { name: /save project settings/i }); + fireEvent.click(saveButton); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith( + expect.objectContaining({ publicUrl: 'https://example.com' }) + ); + }); });