feat: sitemap validation second part
This commit is contained in:
@@ -968,6 +968,8 @@ export class BlogGenerationEngine {
|
|||||||
const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : [];
|
const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : [];
|
||||||
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
|
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
|
||||||
|
|
||||||
|
onProgress(10, 'Planning validation apply steps...');
|
||||||
|
|
||||||
const sections = new Set<BlogGenerationSection>();
|
const sections = new Set<BlogGenerationSection>();
|
||||||
for (const missingPath of missingPaths) {
|
for (const missingPath of missingPaths) {
|
||||||
const normalizedPath = normalizeUrlPath(missingPath);
|
const normalizedPath = normalizeUrlPath(missingPath);
|
||||||
@@ -1011,21 +1013,7 @@ export class BlogGenerationEngine {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let renderedUrlCount = 0;
|
onProgress(20, 'Deleting extra URLs...');
|
||||||
|
|
||||||
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');
|
const htmlDir = path.join(options.dataDir, 'html');
|
||||||
let deletedUrlCount = 0;
|
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);
|
const filePath = urlPathToHtmlIndexPath(htmlDir, urlPath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
@@ -1061,9 +1050,46 @@ export class BlogGenerationEngine {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore missing files and continue
|
// 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 {
|
return {
|
||||||
renderedUrlCount,
|
renderedUrlCount,
|
||||||
|
|||||||
@@ -146,13 +146,31 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
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) => {
|
safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => {
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
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...');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -607,6 +607,64 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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'), '<html>stale</html>', '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 () => {
|
it('generates HTML that references local assets not CDN', async () => {
|
||||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||||
await generate(posts);
|
await generate(posts);
|
||||||
|
|||||||
@@ -2303,6 +2303,77 @@ describe('IPC Handlers', () => {
|
|||||||
'utf-8',
|
'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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user