From cdf064b6a1d32af2fd02f83d851e487cb0456d46 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 21 Feb 2026 15:57:26 +0100 Subject: [PATCH] feat: sitemap validation second part --- src/main/engine/BlogGenerationEngine.ts | 60 +++++++++++++------ src/main/ipc/blogHandlers.ts | 22 ++++++- tests/engine/BlogGenerationEngine.test.ts | 58 ++++++++++++++++++ tests/ipc/handlers.test.ts | 71 +++++++++++++++++++++++ 4 files changed, 192 insertions(+), 19 deletions(-) diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 9391430..56b6e9f 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -968,6 +968,8 @@ export class BlogGenerationEngine { const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : []; const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : []; + onProgress(10, 'Planning validation apply steps...'); + const sections = new Set(); for (const missingPath of missingPaths) { const normalizedPath = normalizeUrlPath(missingPath); @@ -1011,21 +1013,7 @@ export class BlogGenerationEngine { 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...'); + onProgress(20, 'Deleting extra URLs...'); const htmlDir = path.join(options.dataDir, 'html'); let deletedUrlCount = 0; @@ -1052,7 +1040,8 @@ export class BlogGenerationEngine { } }; - for (const urlPath of extraPaths) { + for (let index = 0; index < extraPaths.length; index += 1) { + const urlPath = extraPaths[index]; const filePath = urlPathToHtmlIndexPath(htmlDir, urlPath); try { await fs.unlink(filePath); @@ -1061,9 +1050,46 @@ export class BlogGenerationEngine { } catch { // ignore missing files and continue } + + if (extraPaths.length > 0) { + const deleteProgress = 20 + Math.floor(((index + 1) / extraPaths.length) * 25); + onProgress(Math.min(45, deleteProgress), `Deleted ${index + 1}/${extraPaths.length} extra URLs`); + } } - onProgress(100, `Apply complete (${renderedUrlCount} rendered, ${deletedUrlCount} deleted)`); + let renderedUrlCount = 0; + const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single']; + const orderedSections = sectionExecutionOrder.filter((section) => sections.has(section)); + + if (orderedSections.length > 0) { + for (let index = 0; index < orderedSections.length; index += 1) { + const section = orderedSections[index]; + const sectionLabel = section === 'category' + ? 'categories' + : section === 'tag' + ? 'tags' + : section === 'date' + ? 'calendar' + : section; + + onProgress(50 + Math.floor((index / orderedSections.length) * 40), `Rendering missing ${sectionLabel} routes...`); + + const generationResult = await this.generate({ + ...options, + maxPostsPerPage: options.maxPostsPerPage, + sections: [section], + }, (progress, message) => { + const base = 50 + Math.floor((index / orderedSections.length) * 40); + const span = Math.max(1, Math.floor(40 / orderedSections.length)); + const mapped = base + Math.floor((progress / 100) * span); + onProgress(Math.min(90, mapped), message || `Rendering ${sectionLabel} routes...`); + }); + + renderedUrlCount += generationResult.pagesGenerated; + } + } + + onProgress(100, `Apply complete (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`); return { renderedUrlCount, diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 7247fd5..32268f7 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -146,13 +146,31 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { const blogGenerationEngine = getBlogGenerationEngine(); const baseOptions = await resolveBlogGenerationBaseOptions(); - return blogGenerationEngine.validateSite(baseOptions, () => {}); + const taskTimestamp = Date.now(); + return taskManager.runTask({ + id: `site-validate-${taskTimestamp}`, + name: 'Validate Site', + execute: async (onProgress) => { + return blogGenerationEngine.validateSite(baseOptions, (progress, message) => { + onProgress(progress, message || 'Validating site...'); + }); + }, + }); }); safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => { const blogGenerationEngine = getBlogGenerationEngine(); const baseOptions = await resolveBlogGenerationBaseOptions(); - return blogGenerationEngine.applyValidation(baseOptions, report, () => {}); + const taskTimestamp = Date.now(); + return taskManager.runTask({ + id: `site-validate-apply-${taskTimestamp}`, + name: 'Apply Site Validation', + execute: async (onProgress) => { + return blogGenerationEngine.applyValidation(baseOptions, report, (progress, message) => { + onProgress(progress, message || 'Applying site validation...'); + }); + }, + }); }); } diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index bc440ef..01fe6e2 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -607,6 +607,64 @@ describe('BlogGenerationEngine', () => { expect(sitemap).toContain('https://example.com/page/2/'); }); + it('applies validation by deleting first, then rendering category, tag, and date sections', async () => { + const posts = [ + makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10: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 callOrder: string[] = []; + const generateSpy = vi.spyOn(engine, 'generate').mockImplementation(async (opts) => { + const staleExistsAtRenderTime = await fileExists(path.join(tempDir, 'html', 'stale', 'index.html')); + expect(staleExistsAtRenderTime).toBe(false); + callOrder.push((opts.sections || []).join(',')); + return { + path: path.join(tempDir, 'html', 'sitemap.xml'), + urlCount: 0, + postCount: 0, + feedPostCount: 0, + tagCount: 0, + categoryCount: 0, + archiveCount: 0, + pagesGenerated: 1, + feeds: { + rssPath: path.join(tempDir, 'html', 'rss.xml'), + atomPath: path.join(tempDir, 'html', 'atom.xml'), + }, + changed: { + sitemap: false, + rss: false, + atom: false, + }, + }; + }); + + const result = await engine.applyValidation({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + }, { + sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'), + sitemapChanged: false, + missingUrlPaths: ['/category/news', '/tag/ordered-tag', '/2025/01'], + extraUrlPaths: ['/stale'], + expectedUrlCount: 3, + existingHtmlUrlCount: 1, + }, vi.fn()); + + expect(result.deletedUrlCount).toBe(1); + expect(callOrder).toEqual(['category', 'tag', 'date']); + + generateSpy.mockRestore(); + }); + 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 ce2f82a..c14193a 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -2303,6 +2303,77 @@ describe('IPC Handlers', () => { 'utf-8', ); }); + + it('should run validation via taskManager.runTask', 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.mockResolvedValue([]); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + const { mkdir, writeFile, readdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(readdir).mockResolvedValue([] as never); + + mockTaskManager.runTask.mockImplementation(async (task: any) => { + return task.execute(vi.fn()); + }); + + await invokeHandler('blog:validateSite'); + + expect(mockTaskManager.runTask).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Validate Site', + execute: expect.any(Function), + }), + ); + }); + }); + + describe('blog:applyValidation', () => { + it('should run apply via taskManager.runTask', 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.mockResolvedValue([]); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + const { mkdir, writeFile, readdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(readdir).mockResolvedValue([] as never); + + mockTaskManager.runTask.mockImplementation(async (task: any) => { + return task.execute(vi.fn()); + }); + + await invokeHandler('blog:applyValidation', { + sitemapPath: '/mock/data/dir/html/sitemap.xml', + sitemapChanged: false, + missingUrlPaths: ['/category/news'], + extraUrlPaths: ['/stale'], + expectedUrlCount: 1, + existingHtmlUrlCount: 1, + }); + + expect(mockTaskManager.runTask).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Apply Site Validation', + execute: expect.any(Function), + }), + ); + }); }); });