diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index e9dbd2d..9391430 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -56,6 +56,21 @@ export interface BlogGenerationResult { }; } +export interface SiteValidationReport { + sitemapPath: string; + sitemapChanged: boolean; + missingUrlPaths: string[]; + extraUrlPaths: string[]; + expectedUrlCount: number; + existingHtmlUrlCount: number; +} + +export interface SiteValidationApplyResult { + renderedUrlCount: number; + deletedUrlCount: number; + removedEmptyDirCount: number; +} + export function resolvePublicBaseUrl(publicUrl?: string): string | null { const trimmed = (publicUrl || '').trim(); if (!trimmed) { @@ -141,9 +156,21 @@ function buildSitemapUrl( changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never', priority: string, ): string { + const canonicalLoc = (() => { + try { + const parsed = new URL(loc); + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); + } catch { + return loc.endsWith('/') ? loc : `${loc}/`; + } + })(); + return [ ' ', - ` ${escapeXml(loc)}`, + ` ${escapeXml(canonicalLoc)}`, ` ${escapeXml(lastmod)}`, ` ${changefreq}`, ` ${priority}`, @@ -151,6 +178,78 @@ function buildSitemapUrl( ].join('\n'); } +function normalizeUrlPath(urlPath: string): string { + const trimmed = (urlPath || '').trim(); + if (!trimmed || trimmed === '/') { + return '/'; + } + + const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? ''; + const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, ''); + return withoutSlashes ? `/${withoutSlashes}` : '/'; +} + +function urlPathToHtmlIndexPath(htmlDir: string, urlPath: string): string { + const normalizedPath = normalizeUrlPath(urlPath); + if (normalizedPath === '/') { + return path.join(htmlDir, 'index.html'); + } + + return path.join(htmlDir, normalizedPath.slice(1), 'index.html'); +} + +function sitemapLocToProjectPath(loc: string, baseUrl: string): string { + try { + const locUrl = new URL(loc); + const base = new URL(baseUrl); + const locPath = locUrl.pathname.replace(/\/+$/, ''); + const basePath = base.pathname.replace(/\/+$/, ''); + + if (basePath && locPath.startsWith(basePath)) { + const stripped = locPath.slice(basePath.length); + return normalizeUrlPath(stripped || '/'); + } + + return normalizeUrlPath(locPath || '/'); + } catch { + return normalizeUrlPath(loc); + } +} + +function extractSitemapLocs(sitemapXml: string): string[] { + const matches = sitemapXml.matchAll(/(.*?)<\/loc>/g); + const locs: string[] = []; + for (const match of matches) { + const value = match[1]?.trim(); + if (value) { + locs.push(value); + } + } + return locs; +} + +function appendPaginatedSitemapUrls( + target: string[], + baseUrl: string, + basePath: string, + totalItems: number, + maxPostsPerPage: number, + lastmod: string, + changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never', + priority: string, +): void { + if (totalItems <= 0) { + return; + } + + const totalPages = Math.max(1, Math.ceil(totalItems / maxPostsPerPage)); + for (let page = 2; page <= totalPages; page += 1) { + const normalizedBase = basePath.replace(/\/+$/, ''); + const pagePath = `${normalizedBase}/page/${page}`; + target.push(buildSitemapUrl(`${baseUrl}${pagePath}`, lastmod, changefreq, priority)); + } +} + function splitParagraphs(markdown: string | null | undefined): string[] { const normalizedMarkdown = typeof markdown === 'string' ? markdown : ''; return normalizedMarkdown @@ -299,6 +398,7 @@ export class BlogGenerationEngine { const years = new Map(); const yearMonthDays = new Map(); const postUrls: Array<{ loc: string; lastmod: string }> = []; + const pageUrls: Array<{ loc: string; lastmod: string }> = []; for (const post of publishedPosts) { const createdAt = resolvePostCreatedAt(post); @@ -306,6 +406,17 @@ export class BlogGenerationEngine { const postUrl = `${options.baseUrl}${canonicalPath}`; const updatedAt = post.updatedAt; postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() }); + + const categories = Array.isArray(post.categories) ? post.categories : []; + if (categories.includes('page')) { + const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, ''); + if (trimmedSlug.length > 0) { + pageUrls.push({ + loc: `${options.baseUrl}/${trimmedSlug}`, + lastmod: updatedAt.toISOString(), + }); + } + } } for (const post of publishedListPosts) { @@ -338,26 +449,58 @@ export class BlogGenerationEngine { const urls: string[] = []; urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0')); + appendPaginatedSitemapUrls(urls, options.baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9'); for (const post of postUrls) { urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8')); } + for (const page of pageUrls) { + urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7')); + } 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')); + + const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); + + const [yearStr, monthStr] = ym.split('/'); + const year = Number(yearStr); + const month = Number(monthStr); + const monthCount = publishedListPosts.filter((post) => { + const d = resolvePostCreatedAt(post); + return d.getFullYear() === year && (d.getMonth() + 1) === month; + }).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); + + const [yearStr, monthStr, dayStr] = ymd.split('/'); + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + const dayCount = publishedListPosts.filter((post) => { + const d = resolvePostCreatedAt(post); + return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; + }).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); } for (const category of Array.from(allCategories).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); + + const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } for (const tag of Array.from(allTags).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); + + const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } onProgress(8, 'Building RSS and Atom feeds...'); @@ -568,6 +711,367 @@ export class BlogGenerationEngine { }; } + async validateSite( + options: BlogGenerationOptions, + onProgress: (progress: number, message?: string) => void, + ): Promise { + onProgress(0, 'Collecting sitemap URLs...'); + + const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); + const categorySettings = resolveCategorySettings(options.categorySettings); + const listExcludedCategories = Object.entries(categorySettings) + .filter(([, settings]) => settings.renderInLists === false) + .map(([category]) => category); + + const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); + const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' }); + const publishedListCandidates = await this.postEngine.getPostsFiltered({ + status: 'published', + excludeCategories: listExcludedCategories, + }); + const draftListCandidates = await this.postEngine.getPostsFiltered({ + status: 'draft', + excludeCategories: listExcludedCategories, + }); + + 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 publishedListSnapshots = await Promise.all( + publishedListCandidates.map(async (post) => { + const snapshot = await this.postEngine.getPublishedVersion(post.id); + return snapshot || post; + }), + ); + const draftListPublishedSnapshots = await Promise.all( + draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), + ); + + const publishedPostById = new Map(); + 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 publishedListPostById = new Map(); + for (const post of publishedListSnapshots) { + publishedListPostById.set(post.id, post); + } + for (const snapshot of draftListPublishedSnapshots) { + if (snapshot) { + publishedListPostById.set(snapshot.id, snapshot); + } + } + const publishedListPosts = Array.from(publishedListPostById.values()) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const now = new Date().toISOString(); + const allTags = new Set(); + const allCategories = new Set(); + const yearMonths = new Map(); + const years = new Map(); + const yearMonthDays = new Map(); + const postUrls: Array<{ loc: string; lastmod: string }> = []; + const pageUrls: Array<{ loc: string; lastmod: string }> = []; + + for (const post of publishedPosts) { + 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 categories = Array.isArray(post.categories) ? post.categories : []; + if (categories.includes('page')) { + const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, ''); + if (trimmedSlug.length > 0) { + pageUrls.push({ + loc: `${options.baseUrl}/${trimmedSlug}`, + lastmod: updatedAt.toISOString(), + }); + } + } + } + + for (const post of publishedListPosts) { + for (const tag of post.tags || []) allTags.add(tag); + for (const category of post.categories || []) allCategories.add(category); + + const createdAt = resolvePostCreatedAt(post); + const updatedAt = post.updatedAt; + + 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 = publishedListPosts[0]?.updatedAt.toISOString() || now; + + const urls: string[] = []; + urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0')); + appendPaginatedSitemapUrls(urls, options.baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9'); + for (const post of postUrls) { + urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8')); + } + for (const page of pageUrls) { + urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7')); + } + + 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')); + + const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); + } + for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { + urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); + + const [yearStr, monthStr] = ym.split('/'); + const year = Number(yearStr); + const month = Number(monthStr); + const monthCount = publishedListPosts.filter((post) => { + const d = resolvePostCreatedAt(post); + return d.getFullYear() === year && (d.getMonth() + 1) === month; + }).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); + } + for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { + urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); + + const [yearStr, monthStr, dayStr] = ymd.split('/'); + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + const dayCount = publishedListPosts.filter((post) => { + const d = resolvePostCreatedAt(post); + return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; + }).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); + } + + for (const category of Array.from(allCategories).sort()) { + urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); + + const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); + } + + for (const tag of Array.from(allTags).sort()) { + urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); + + const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length; + appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); + } + + const sitemapXml = [ + '', + '', + ...urls, + '', + '', + ].join('\n'); + + const htmlDir = path.join(options.dataDir, 'html'); + await fs.mkdir(htmlDir, { recursive: true }); + const sitemapPath = path.join(htmlDir, 'sitemap.xml'); + const sitemapChanged = await writeFileIfHashChanged(options.projectId, sitemapPath, 'sitemap.xml', sitemapXml); + + onProgress(50, 'Comparing sitemap to html pages...'); + + const expectedPathSet = new Set( + extractSitemapLocs(sitemapXml) + .map((loc) => sitemapLocToProjectPath(loc, options.baseUrl)) + .map((value) => normalizeUrlPath(value)), + ); + + const existingHtmlPathSet = new Set(); + const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise => { + let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>; + try { + entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; + } + + for (const entry of entries) { + const nextRelative = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + const nextPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await collectIndexPaths(nextPath, nextRelative); + continue; + } + + if (!entry.isFile() || entry.name !== 'index.html') { + continue; + } + + const normalizedRelative = nextRelative.replace(/(^|\/)index\.html$/, ''); + existingHtmlPathSet.add(normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/')); + } + }; + + await collectIndexPaths(htmlDir); + + const missingUrlPaths = Array.from(expectedPathSet) + .filter((value) => !existingHtmlPathSet.has(value)) + .sort(); + + const extraUrlPaths = Array.from(existingHtmlPathSet) + .filter((value) => !expectedPathSet.has(value)) + .sort(); + + onProgress(100, `Validation complete (${missingUrlPaths.length} missing, ${extraUrlPaths.length} extra)`); + + return { + sitemapPath, + sitemapChanged, + missingUrlPaths, + extraUrlPaths, + expectedUrlCount: expectedPathSet.size, + existingHtmlUrlCount: existingHtmlPathSet.size, + }; + } + + async applyValidation( + options: BlogGenerationOptions, + report: SiteValidationReport, + onProgress: (progress: number, message?: string) => void, + ): Promise { + onProgress(0, 'Applying validation changes...'); + + const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : []; + const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : []; + + const sections = new Set(); + for (const missingPath of missingPaths) { + const normalizedPath = normalizeUrlPath(missingPath); + + if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) { + sections.add('core'); + continue; + } + + if (/^\/category\//.test(normalizedPath)) { + sections.add('category'); + continue; + } + + if (/^\/tag\//.test(normalizedPath)) { + sections.add('tag'); + continue; + } + + if (/^\/\d{4}\/\d{2}\/\d{2}\/[^/]+$/.test(normalizedPath)) { + sections.add('single'); + continue; + } + + if (/^\/\d{4}(?:\/\d{2}(?:\/\d{2})?)?(?:\/page\/\d+)?$/.test(normalizedPath)) { + sections.add('date'); + continue; + } + + if (/^\/[^/]+$/.test(normalizedPath)) { + sections.add('core'); + continue; + } + + sections.clear(); + sections.add('core'); + sections.add('single'); + sections.add('category'); + sections.add('tag'); + sections.add('date'); + break; + } + + let renderedUrlCount = 0; + + if (sections.size > 0) { + onProgress(20, 'Rendering missing URLs...'); + const generationResult = await this.generate({ + ...options, + maxPostsPerPage: options.maxPostsPerPage, + sections: Array.from(sections), + }, (progress, message) => { + onProgress(Math.min(70, 20 + Math.floor(progress * 0.5)), message); + }); + renderedUrlCount = generationResult.pagesGenerated; + } + + onProgress(75, 'Deleting extra URLs...'); + + const htmlDir = path.join(options.dataDir, 'html'); + let deletedUrlCount = 0; + let removedEmptyDirCount = 0; + + const pruneEmptyParents = async (startDir: string): Promise => { + let currentDir = startDir; + + while (path.resolve(currentDir) !== path.resolve(htmlDir)) { + let entries: string[]; + try { + entries = await fs.readdir(currentDir); + } catch { + break; + } + + if (entries.length > 0) { + break; + } + + await fs.rm(currentDir, { recursive: true, force: true }); + removedEmptyDirCount += 1; + currentDir = path.dirname(currentDir); + } + }; + + for (const urlPath of extraPaths) { + const filePath = urlPathToHtmlIndexPath(htmlDir, urlPath); + try { + await fs.unlink(filePath); + deletedUrlCount += 1; + await pruneEmptyParents(path.dirname(filePath)); + } catch { + // ignore missing files and continue + } + } + + onProgress(100, `Apply complete (${renderedUrlCount} rendered, ${deletedUrlCount} deleted)`); + + return { + renderedUrlCount, + deletedUrlCount, + removedEmptyDirCount, + }; + } + private async generatePageRoutes( projectId: string, posts: PostData[], diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 13531f2..7247fd5 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -10,19 +10,20 @@ import { resolvePublicBaseUrl, type BlogGenerationResult, type BlogGenerationSection, + type BlogGenerationOptions, + type SiteValidationReport, } from '../engine/BlogGenerationEngine'; import { resolvePageTitle } from '../engine/PageRenderer'; type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; export function registerBlogHandlers(safeHandle: SafeHandle): void { - safeHandle('blog:generateSitemap', async () => { + const resolveBlogGenerationBaseOptions = async (): Promise => { const projectEngine = getProjectEngine(); const postEngine = getPostEngine(); const metaEngine = getMetaEngine(); const mediaEngine = getMediaEngine(); const postMediaEngine = getPostMediaEngine(); - const blogGenerationEngine = getBlogGenerationEngine(); const project = await projectEngine.getActiveProject(); if (!project) { @@ -51,12 +52,10 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { throw new Error('Project public URL is not configured'); } - const taskTimestamp = Date.now(); - const taskGroupId = `site-render-${taskTimestamp}`; - const taskGroupName = 'Render Site'; const language = metadata?.mainLanguage?.trim() || 'en'; const pageTitle = resolvePageTitle(metadata, project.name, project.description ?? undefined); - const baseOptions = { + + return { projectId: project.id, projectName: metadata?.name?.trim() || project.name, projectDescription: metadata?.description, @@ -68,6 +67,15 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { picoTheme: metadata?.picoTheme, categorySettings: (metadata as any)?.categorySettings, }; + }; + + safeHandle('blog:generateSitemap', async () => { + const blogGenerationEngine = getBlogGenerationEngine(); + const baseOptions = await resolveBlogGenerationBaseOptions(); + + const taskTimestamp = Date.now(); + const taskGroupId = `site-render-${taskTimestamp}`; + const taskGroupName = 'Render Site'; const runSectionTask = async ( section: BlogGenerationSection, @@ -133,4 +141,18 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { return mergeResults([coreResult, singleResult, categoryResult, tagResult, dateResult]); }); + + safeHandle('blog:validateSite', async () => { + const blogGenerationEngine = getBlogGenerationEngine(); + const baseOptions = await resolveBlogGenerationBaseOptions(); + + return blogGenerationEngine.validateSite(baseOptions, () => {}); + }); + + safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => { + const blogGenerationEngine = getBlogGenerationEngine(); + const baseOptions = await resolveBlogGenerationBaseOptions(); + + return blogGenerationEngine.applyValidation(baseOptions, report, () => {}); + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 9c71aa7..f0540c9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { ElectronAPI } from './shared/electronApi'; import type { GitInitProgress } from './shared/electronApi'; +import type { SiteValidationReport } from './shared/electronApi'; // Expose protected methods that allow the renderer process to use // ipcRenderer without exposing the entire object @@ -253,6 +254,8 @@ export const electronAPI: ElectronAPI = { // Blog operations blog: { generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'), + validateSite: () => ipcRenderer.invoke('blog:validateSite'), + applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report), }, // AI Chat (OpenCode Zen API integration) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 02ad63b..3d8911a 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -407,6 +407,21 @@ export interface ChatTitleUpdate { title: string; } +export interface SiteValidationReport { + sitemapPath: string; + sitemapChanged: boolean; + missingUrlPaths: string[]; + extraUrlPaths: string[]; + expectedUrlCount: number; + existingHtmlUrlCount: number; +} + +export interface SiteValidationApplyResult { + renderedUrlCount: number; + deletedUrlCount: number; + removedEmptyDirCount: number; +} + export interface ElectronAPI { git: { checkAvailability: () => Promise; @@ -611,6 +626,8 @@ export interface ElectronAPI { archiveCount: number; pagesGenerated: number; }>; + validateSite: () => Promise; + applyValidation: (report: SiteValidationReport) => Promise; }; chat: { // API Key Management diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index edd5d39..9659e79 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -36,6 +36,7 @@ "menu.item.reindexText": "Suchtext neu indizieren", "menu.item.metadataDiff": "Metadaten-Diff-Werkzeug", "menu.item.generateSitemap": "Site rendern", + "menu.item.validateSite": "Website validieren", "menu.item.about": "Über Blogging Desktop Server", "menu.item.openDocumentation": "Dokumentation öffnen", "menu.item.viewOnGitHub": "Auf GitHub ansehen", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index 0598339..9038cc6 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -36,6 +36,7 @@ "menu.item.reindexText": "Reindex Search Text", "menu.item.metadataDiff": "Metadata Diff Tool", "menu.item.generateSitemap": "Render Site", + "menu.item.validateSite": "Validate Site", "menu.item.about": "About Blogging Desktop Server", "menu.item.openDocumentation": "Open Documentation", "menu.item.viewOnGitHub": "View on GitHub", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 55b63eb..7d31b4c 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -36,6 +36,7 @@ "menu.item.reindexText": "Reindex Buscar Text", "menu.item.metadataDiff": "Herramienta diff de metadatos", "menu.item.generateSitemap": "Renderizar sitio", + "menu.item.validateSite": "Validar sitio", "menu.item.about": "Acerca de Blogging Desktop Server", "menu.item.openDocumentation": "Abrir documentación", "menu.item.viewOnGitHub": "Ver en GitHub", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index ad57d26..11c6074 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -36,6 +36,7 @@ "menu.item.reindexText": "Reindex Recherche Text", "menu.item.metadataDiff": "Outil de diff des métadonnées", "menu.item.generateSitemap": "Rendre le site", + "menu.item.validateSite": "Valider le site", "menu.item.about": "À propos de Blogging Desktop Server", "menu.item.openDocumentation": "Ouvrir la documentation", "menu.item.viewOnGitHub": "Voir sur GitHub", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index f5c6252..6fceedc 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -36,6 +36,7 @@ "menu.item.reindexText": "Reindex Ricerca Text", "menu.item.metadataDiff": "Strumento diff metadati", "menu.item.generateSitemap": "Renderizza sito", + "menu.item.validateSite": "Valida sito", "menu.item.about": "Informazioni su Blogging Desktop Server", "menu.item.openDocumentation": "Apri documentazione", "menu.item.viewOnGitHub": "Visualizza su GitHub", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index 667e7d5..95eebc2 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -31,6 +31,7 @@ export type AppMenuAction = | 'reindexText' | 'metadataDiff' | 'generateSitemap' + | 'validateSite' | 'openDocumentation' | 'about' | 'viewOnGitHub' @@ -121,6 +122,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [ { label: '', action: 'blog-separator-3', separator: true }, { label: 'menu.item.metadataDiff', action: 'metadataDiff' }, { label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' }, + { label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Alt+V' }, ], }, { @@ -152,6 +154,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> = reindexText: 'menu:reindexText', metadataDiff: 'menu:metadataDiff', generateSitemap: 'menu:generateSitemap', + validateSite: 'menu:validateSite', openDocumentation: 'menu:openDocumentation', about: 'menu:about', }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6ff2ca0..072661f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -304,6 +304,12 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:validateSite', () => { + openTab({ id: 'site-validation-report', type: 'site-validation', isTransient: true }); + }) || (() => {}) + ); + unsubscribers.push( window.electronAPI?.on('menu:previewPost', async () => { try { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index a0cce3a..7cab720 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -17,6 +17,7 @@ import { ImportAnalysisView } from '../ImportAnalysisView'; import { MetadataDiffPanel } from '../MetadataDiffPanel'; import { GitDiffView } from '../GitDiffView/GitDiffView'; import { DocumentationView } from '../DocumentationView/DocumentationView'; +import { SiteValidationView } from '../SiteValidationView'; import { AutoSaveManager, getContrastColor } from '../../utils'; import { InsertModal } from '../InsertModal'; import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; @@ -1735,6 +1736,7 @@ export const Editor: React.FC = () => { const showMetadataDiff = activeTab?.type === 'metadata-diff'; const showGitDiff = activeTab?.type === 'git-diff'; const showDocumentation = activeTab?.type === 'documentation'; + const showSiteValidation = activeTab?.type === 'site-validation'; useEffect(() => { const activePostId = activeTab?.type === 'post' ? activeTab.id : null; @@ -1873,6 +1875,16 @@ export const Editor: React.FC = () => { ); } + if (showSiteValidation) { + return ( +
+ + {renderErrorModal()} + {renderConfirmDeleteModal()} +
+ ); + } + // Show post editor if a post tab is active if (showPost && activeTabId) { return ( diff --git a/src/renderer/components/SiteValidationView/SiteValidationView.css b/src/renderer/components/SiteValidationView/SiteValidationView.css new file mode 100644 index 0000000..abce8b0 --- /dev/null +++ b/src/renderer/components/SiteValidationView/SiteValidationView.css @@ -0,0 +1,70 @@ +.site-validation-view { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + height: 100%; + overflow: auto; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +.site-validation-summary h2 { + margin: 0 0 8px 0; + font-size: 1.1rem; +} + +.site-validation-summary p { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.site-validation-section h3 { + margin: 0 0 8px 0; + font-size: 1rem; +} + +.site-validation-list { + margin: 0; + padding-left: 20px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; +} + +.site-validation-list-missing { + color: var(--vscode-testing-iconPassed); +} + +.site-validation-list-extra { + color: var(--vscode-notificationsErrorIcon-foreground); +} + +.site-validation-empty, +.site-validation-status { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.site-validation-actions { + margin-top: auto; + display: flex; + justify-content: flex-end; +} + +.site-validation-apply { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; +} + +.site-validation-apply:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground); +} + +.site-validation-apply:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/src/renderer/components/SiteValidationView/SiteValidationView.tsx b/src/renderer/components/SiteValidationView/SiteValidationView.tsx new file mode 100644 index 0000000..5fe1e0b --- /dev/null +++ b/src/renderer/components/SiteValidationView/SiteValidationView.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { showToast } from '../Toast'; +import { useI18n } from '../../i18n'; +import './SiteValidationView.css'; + +type SiteValidationReport = { + sitemapPath: string; + sitemapChanged: boolean; + missingUrlPaths: string[]; + extraUrlPaths: string[]; + expectedUrlCount: number; + existingHtmlUrlCount: number; +}; + +type SiteValidationApplyResult = { + renderedUrlCount: number; + deletedUrlCount: number; + removedEmptyDirCount: number; +}; + +export const SiteValidationView: React.FC = () => { + const { t: tr } = useI18n(); + const [isLoading, setIsLoading] = useState(true); + const [isApplying, setIsApplying] = useState(false); + const [report, setReport] = useState(null); + + const loadReport = async () => { + setIsLoading(true); + try { + const result = await window.electronAPI.blog.validateSite(); + setReport(result as SiteValidationReport); + } catch (error) { + console.error('Site validation failed:', error); + showToast.error(tr('siteValidation.error.validate')); + setReport(null); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadReport(); + }, []); + + const canApply = useMemo(() => { + if (!report) return false; + return report.missingUrlPaths.length > 0 || report.extraUrlPaths.length > 0; + }, [report]); + + const handleApply = async () => { + if (!report || !canApply) { + return; + } + + setIsApplying(true); + try { + const result = await window.electronAPI.blog.applyValidation(report) as SiteValidationApplyResult; + showToast.success(tr('siteValidation.toast.applySuccess', { + rendered: result.renderedUrlCount, + deleted: result.deletedUrlCount, + })); + await loadReport(); + } catch (error) { + console.error('Applying site validation failed:', error); + showToast.error(tr('siteValidation.error.apply')); + } finally { + setIsApplying(false); + } + }; + + if (isLoading) { + return ( +
+

{tr('siteValidation.loading')}

+
+ ); + } + + if (!report) { + return ( +
+

{tr('siteValidation.error.validate')}

+
+ ); + } + + return ( +
+
+

{tr('siteValidation.title')}

+

{tr('siteValidation.summary', { + expected: report.expectedUrlCount, + existing: report.existingHtmlUrlCount, + missing: report.missingUrlPaths.length, + extra: report.extraUrlPaths.length, + })}

+
+ +
+

{tr('siteValidation.missingTitle')}

+ {report.missingUrlPaths.length === 0 ? ( +

{tr('siteValidation.noneMissing')}

+ ) : ( +
    + {report.missingUrlPaths.map((urlPath) => ( +
  • {urlPath}
  • + ))} +
+ )} +
+ +
+

{tr('siteValidation.extraTitle')}

+ {report.extraUrlPaths.length === 0 ? ( +

{tr('siteValidation.noneExtra')}

+ ) : ( +
    + {report.extraUrlPaths.map((urlPath) => ( +
  • {urlPath}
  • + ))} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/src/renderer/components/SiteValidationView/index.ts b/src/renderer/components/SiteValidationView/index.ts new file mode 100644 index 0000000..7385fd3 --- /dev/null +++ b/src/renderer/components/SiteValidationView/index.ts @@ -0,0 +1 @@ +export { SiteValidationView } from './SiteValidationView'; diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 7bc3d80..8070fc8 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -84,6 +84,10 @@ const getTabTitle = ( return tr('docs.title'); } + if (tab.type === 'site-validation') { + return tr('siteValidation.tabTitle'); + } + return tr('tabBar.unknown'); }; @@ -150,6 +154,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => { ); + case 'site-validation': + return ( + + + + ); default: return ( diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index a6afcfd..03ccdcc 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -25,3 +25,4 @@ export { ImportAnalysisView } from './ImportAnalysisView'; export { InsertModal } from './InsertModal'; export { WindowTitleBar } from './WindowTitleBar'; export { DocumentationView } from './DocumentationView/DocumentationView'; +export { SiteValidationView } from './SiteValidationView'; diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 0c75f54..8043ec6 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -28,6 +28,19 @@ "app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden", "app.metadataDiff": "Metadaten-Diff", "app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien", + "siteValidation.tabTitle": "Website-Validierung", + "siteValidation.title": "Website validieren", + "siteValidation.summary": "Erwartete URLs: {expected} · Vorhandene HTML-URLs: {existing} · Fehlend: {missing} · Überzählig: {extra}", + "siteValidation.loading": "Website wird validiert...", + "siteValidation.missingTitle": "Fehlende HTML-URLs (zum Rendern)", + "siteValidation.extraTitle": "Nicht referenzierte HTML-URLs (zum Löschen)", + "siteValidation.noneMissing": "Keine fehlenden URLs gefunden.", + "siteValidation.noneExtra": "Keine überzähligen URLs gefunden.", + "siteValidation.apply": "Anwenden", + "siteValidation.applying": "Wird angewendet...", + "siteValidation.error.validate": "Website-Validierung fehlgeschlagen", + "siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen", + "siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht", "settings.language.english": "Englisch", "settings.language.german": "Deutsch", "settings.language.french": "Französisch", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index ff0e6d0..b34af7c 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -28,6 +28,19 @@ "app.previewOpenFailed": "Failed to open selected post preview", "app.metadataDiff": "Metadata Diff", "app.importComplete": "Import complete: {posts} posts, {media} media files", + "siteValidation.tabTitle": "Site Validation", + "siteValidation.title": "Validate Site", + "siteValidation.summary": "Expected URLs: {expected} · Existing HTML URLs: {existing} · Missing: {missing} · Extra: {extra}", + "siteValidation.loading": "Validating site...", + "siteValidation.missingTitle": "Missing HTML URLs (to render)", + "siteValidation.extraTitle": "Unreferenced HTML URLs (to delete)", + "siteValidation.noneMissing": "No missing URLs found.", + "siteValidation.noneExtra": "No extra URLs found.", + "siteValidation.apply": "Apply", + "siteValidation.applying": "Applying...", + "siteValidation.error.validate": "Site validation failed", + "siteValidation.error.apply": "Applying validation failed", + "siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted", "settings.language.english": "English", "settings.language.german": "German", "settings.language.french": "French", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 9077f71..9a8858e 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -28,6 +28,19 @@ "app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada", "app.metadataDiff": "Diferencia de Metadatos", "app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia", + "siteValidation.tabTitle": "Validación del sitio", + "siteValidation.title": "Validar sitio", + "siteValidation.summary": "URLs esperadas: {expected} · URLs HTML existentes: {existing} · Faltantes: {missing} · Sobrantes: {extra}", + "siteValidation.loading": "Validando el sitio...", + "siteValidation.missingTitle": "URLs HTML faltantes (para renderizar)", + "siteValidation.extraTitle": "URLs HTML no referenciadas (para eliminar)", + "siteValidation.noneMissing": "No se encontraron URLs faltantes.", + "siteValidation.noneExtra": "No se encontraron URLs sobrantes.", + "siteValidation.apply": "Aplicar", + "siteValidation.applying": "Aplicando...", + "siteValidation.error.validate": "La validación del sitio falló", + "siteValidation.error.apply": "La aplicación de la validación falló", + "siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas", "settings.language.english": "Inglés", "settings.language.german": "Alemán", "settings.language.french": "Francés", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index c96e3a3..911e4d9 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -28,6 +28,19 @@ "app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné", "app.metadataDiff": "Diff Métadonnées", "app.importComplete": "Import terminé : {posts} articles, {media} fichiers média", + "siteValidation.tabTitle": "Validation du site", + "siteValidation.title": "Valider le site", + "siteValidation.summary": "URLs attendues : {expected} · URLs HTML existantes : {existing} · Manquantes : {missing} · En trop : {extra}", + "siteValidation.loading": "Validation du site en cours...", + "siteValidation.missingTitle": "URLs HTML manquantes (à rendre)", + "siteValidation.extraTitle": "URLs HTML non référencées (à supprimer)", + "siteValidation.noneMissing": "Aucune URL manquante trouvée.", + "siteValidation.noneExtra": "Aucune URL en trop trouvée.", + "siteValidation.apply": "Appliquer", + "siteValidation.applying": "Application en cours...", + "siteValidation.error.validate": "Échec de la validation du site", + "siteValidation.error.apply": "Échec de l’application de la validation", + "siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées", "settings.language.english": "Anglais", "settings.language.german": "Allemand", "settings.language.french": "Français", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 62eb70a..8c6c941 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -28,6 +28,19 @@ "app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato", "app.metadataDiff": "Diff Metadati", "app.importComplete": "Import completato: {posts} post, {media} file multimediali", + "siteValidation.tabTitle": "Validazione sito", + "siteValidation.title": "Valida sito", + "siteValidation.summary": "URL attesi: {expected} · URL HTML esistenti: {existing} · Mancanti: {missing} · Extra: {extra}", + "siteValidation.loading": "Validazione del sito in corso...", + "siteValidation.missingTitle": "URL HTML mancanti (da renderizzare)", + "siteValidation.extraTitle": "URL HTML non referenziati (da eliminare)", + "siteValidation.noneMissing": "Nessun URL mancante trovato.", + "siteValidation.noneExtra": "Nessun URL extra trovato.", + "siteValidation.apply": "Applica", + "siteValidation.applying": "Applicazione in corso...", + "siteValidation.error.validate": "Validazione del sito non riuscita", + "siteValidation.error.apply": "Applicazione della validazione non riuscita", + "siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati", "settings.language.english": "Inglese", "settings.language.german": "Tedesco", "settings.language.french": "Francese", diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 6b3fb41..5dc9767 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -12,7 +12,7 @@ import type { const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation'; +export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation'; export interface Tab { type: TabType; diff --git a/src/renderer/utils/tabPersistence.ts b/src/renderer/utils/tabPersistence.ts index 5460f65..5833a9d 100644 --- a/src/renderer/utils/tabPersistence.ts +++ b/src/renderer/utils/tabPersistence.ts @@ -4,7 +4,15 @@ const TAB_STATE_PREFIX = 'bds-tabs-'; export const saveTabsForProject = (projectId: string, tabState: TabState): void => { try { - localStorage.setItem(`${TAB_STATE_PREFIX}${projectId}`, JSON.stringify(tabState)); + const persistentTabs = tabState.tabs.filter((tab) => tab.isTransient !== true); + const persistedState: TabState = { + tabs: persistentTabs, + activeTabId: persistentTabs.some((tab) => tab.id === tabState.activeTabId) + ? tabState.activeTabId + : (persistentTabs[0]?.id ?? null), + }; + + localStorage.setItem(`${TAB_STATE_PREFIX}${projectId}`, JSON.stringify(persistedState)); } catch (error) { console.error('Failed to save tab state:', error); } diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 80c4ce6..bc440ef 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, readFile, rm, readdir, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; import type { PostData } from '../../src/main/engine/PostEngine'; @@ -442,6 +442,171 @@ describe('BlogGenerationEngine', () => { expect(result.pagesGenerated).toBe(7); }); + it('validates sitemap against html folder without rendering missing pages', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'validation-main-post', + title: 'Validation Main Post', + categories: ['news'], + tags: ['validation-tag'], + createdAt: new Date('2025-01-15T10:00:00Z'), + }), + makePost({ + id: '2', + slug: 'validation-page', + title: 'Validation Page', + categories: ['page'], + tags: [], + createdAt: new Date('2025-01-16T10:00:00Z'), + }), + ]; + setupPosts(posts); + + await mkdir(path.join(tempDir, 'html', 'stale'), { recursive: true }); + await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), 'stale', 'utf-8'); + + const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); + const engine = new BlogGenerationEngine(); + + const report = await engine.validateSite({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + }, vi.fn()); + + expect(report.missingUrlPaths).toContain('/2025/01/15/validation-main-post'); + expect(report.missingUrlPaths).toContain('/category/news'); + expect(report.missingUrlPaths).toContain('/tag/validation-tag'); + expect(report.missingUrlPaths).toContain('/validation-page'); + expect(report.extraUrlPaths).toContain('/stale'); + + expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'validation-main-post', 'index.html'))).toBe(false); + expect(await fileExists(path.join(tempDir, 'html', 'sitemap.xml'))).toBe(true); + }); + + it('applies validation by rendering missing pages and deleting extra pages with folder pruning', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'apply-post', + title: 'Apply Post', + categories: ['news'], + tags: ['apply-tag'], + createdAt: new Date('2025-01-15T10:00:00Z'), + }), + ]; + setupPosts(posts); + + await mkdir(path.join(tempDir, 'html', 'obsolete', 'deep'), { recursive: true }); + await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), 'obsolete', 'utf-8'); + + const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); + const engine = new BlogGenerationEngine(); + + const report = await engine.validateSite({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + }, vi.fn()); + + const applyResult = await engine.applyValidation({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + }, report, vi.fn()); + + expect(applyResult.deletedUrlCount).toBeGreaterThan(0); + expect(applyResult.renderedUrlCount).toBeGreaterThan(0); + + expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'))).toBe(false); + expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep'))).toBe(false); + expect(await fileExists(path.join(tempDir, 'html', 'obsolete'))).toBe(false); + expect(await fileExists(path.join(tempDir, 'html'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'apply-post', 'index.html'))).toBe(true); + }); + + it('does not report valid pagination routes as extra html content', async () => { + const posts = [ + makePost({ id: '1', slug: 'p1', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-15T10:00:00Z') }), + makePost({ id: '2', slug: 'p2', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-14T10:00:00Z') }), + makePost({ id: '3', slug: 'p3', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-13T10:00:00Z') }), + ]; + + setupPosts(posts); + + const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); + const engine = new BlogGenerationEngine(); + + await engine.generate({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + maxPostsPerPage: 2, + }, vi.fn()); + + const report = await engine.validateSite({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + maxPostsPerPage: 2, + }, vi.fn()); + + expect(report.extraUrlPaths).not.toContain('/page/2'); + expect(report.extraUrlPaths).not.toContain('/category/news/page/2'); + expect(report.extraUrlPaths).not.toContain('/tag/tag-news/page/2'); + }); + + it('emits sitemap urls with trailing slash canonical form', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'canonical-post', + categories: ['news'], + tags: ['canonical-tag'], + createdAt: new Date('2025-01-15T10:00:00Z'), + }), + makePost({ + id: '2', + slug: 'canonical-post-2', + categories: ['news'], + tags: ['canonical-tag'], + createdAt: new Date('2025-01-14T10:00:00Z'), + }), + makePost({ + id: '3', + slug: 'canonical-post-3', + categories: ['news'], + tags: ['canonical-tag'], + createdAt: new Date('2025-01-13T10:00:00Z'), + }), + makePost({ + id: '4', + slug: 'canonical-page', + categories: ['page'], + tags: [], + createdAt: new Date('2025-01-12T10:00:00Z'), + }), + ]; + + await generate(posts, { maxPostsPerPage: 2 }); + + const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8'); + + expect(sitemap).toContain('https://example.com/'); + expect(sitemap).toContain('https://example.com/2025/01/15/canonical-post/'); + expect(sitemap).toContain('https://example.com/category/news/'); + expect(sitemap).toContain('https://example.com/category/news/page/2/'); + expect(sitemap).toContain('https://example.com/tag/canonical-tag/'); + expect(sitemap).toContain('https://example.com/canonical-page/'); + expect(sitemap).toContain('https://example.com/page/2/'); + }); + it('generates HTML that references local assets not CDN', async () => { const posts = [makePost({ id: '1', slug: 'test' })]; await generate(posts); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index b8176a7..ce2f82a 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -2246,6 +2246,64 @@ describe('IPC Handlers', () => { expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post'); }); }); + + describe('blog:validateSite', () => { + it('should generate sitemap-only validation report against html folder', 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', + title: 'Test Post', + slug: 'test-post', + excerpt: '', + content: '# Test', + 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: ['tag1'], + categories: ['category1'], + }, + ]; + } + if (filter.status === 'draft') { + return []; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + const { writeFile, mkdir, readdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(readdir).mockResolvedValue([] as never); + + const result = await invokeHandler('blog:validateSite'); + + expect(result).toEqual(expect.objectContaining({ + missingUrlPaths: expect.any(Array), + extraUrlPaths: expect.any(Array), + })); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('sitemap.xml'), + expect.stringContaining(''), + 'utf-8', + ); + }); + }); }); // ============ Error Handling ============ diff --git a/tests/renderer/menuCommands.test.ts b/tests/renderer/menuCommands.test.ts index 4ec5bd9..ad466bd 100644 --- a/tests/renderer/menuCommands.test.ts +++ b/tests/renderer/menuCommands.test.ts @@ -40,11 +40,15 @@ describe('Help menu documentation entry', () => { expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true); }); - it('assigns Command/Ctrl+R shortcut for generateSitemap menu item', () => { + it('includes Validate Site action in Blog menu with a V shortcut', () => { const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog'); - const generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap'); + const validateSiteItem = blogGroup?.items.find((item) => item.action === 'validateSite'); - expect(generateSiteItem).toBeDefined(); - expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R'); + expect(validateSiteItem).toBeDefined(); + expect(validateSiteItem?.accelerator).toContain('V'); + }); + + it('maps Validate Site to a renderer menu event', () => { + expect(APP_MENU_ACTION_EVENT_MAP.validateSite).toBe('menu:validateSite'); }); }); diff --git a/tests/renderer/utils/tabPersistence.test.ts b/tests/renderer/utils/tabPersistence.test.ts index d7cd060..ef63815 100644 --- a/tests/renderer/utils/tabPersistence.test.ts +++ b/tests/renderer/utils/tabPersistence.test.ts @@ -47,4 +47,23 @@ describe('tabPersistence', () => { expect(() => saveTabsForProject(projectId, sampleTabState)).not.toThrow(); }); + + it('does not persist transient tabs', () => { + const tabStateWithTransient = { + tabs: [ + { id: 'documentation', type: 'documentation', isTransient: false }, + { id: 'site-validation-report', type: 'site-validation', isTransient: true }, + ], + activeTabId: 'site-validation-report', + } as unknown as TabState; + + saveTabsForProject(projectId, tabStateWithTransient); + + const loaded = loadTabsForProject(projectId); + + expect(loaded?.tabs).toEqual([ + { id: 'documentation', type: 'documentation', isTransient: false }, + ]); + expect(loaded?.activeTabId).toBe('documentation'); + }); });