diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 2ff46e2..73b3b39 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -1459,9 +1459,80 @@ export class BlogGenerationEngine { projectDescription: options.projectDescription, }; + const routeHtmlCache = new Map>(); + const mediaItemsPromiseCache = new Map>>>(); + const postsByFilterPromiseCache = new Map>(); + const publishedSnapshotByIdPromiseCache = new Map>(); + type PostFilterInput = Parameters[0]; + + const serializeFilter = (filter: PostFilterInput): string => { + const normalizeValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map((entry) => normalizeValue(entry)); + } + + if (value && typeof value === 'object') { + const sortedEntries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nestedValue]) => [key, normalizeValue(nestedValue)] as const); + return Object.fromEntries(sortedEntries); + } + + return value; + }; + + return JSON.stringify(normalizeValue(filter)); + }; + + const cachedPostEngine = { + getPostsFiltered: (filter: PostFilterInput) => { + const cacheKey = serializeFilter(filter); + const cached = postsByFilterPromiseCache.get(cacheKey); + if (cached) { + return cached; + } + + const promise = this.postEngine.getPostsFiltered(filter); + postsByFilterPromiseCache.set(cacheKey, promise); + return promise; + }, + getPublishedVersion: (postId: string) => { + const cached = publishedSnapshotByIdPromiseCache.get(postId); + if (cached) { + return cached; + } + + const promise = this.postEngine.getPublishedVersion(postId); + publishedSnapshotByIdPromiseCache.set(postId, promise); + return promise; + }, + getPost: (postId: string) => this.postEngine.getPost(postId), + hasPublishedVersion: (postId: string) => this.postEngine.hasPublishedVersion(postId), + setProjectContext: (projectId: string, dataDir?: string) => { + this.postEngine.setProjectContext(projectId, dataDir); + }, + }; + + const cachedMediaEngine = { + getAllMedia: () => { + const cacheKey = `${options.projectId}:${options.dataDir ?? ''}`; + const cached = mediaItemsPromiseCache.get(cacheKey); + if (cached) { + return cached; + } + + const promise = this.mediaEngine.getAllMedia(); + mediaItemsPromiseCache.set(cacheKey, promise); + return promise; + }, + setProjectContext: (projectId: string, dataDir?: string, internalDir?: string) => { + this.mediaEngine.setProjectContext?.(projectId, dataDir, internalDir); + }, + }; + const previewServer = new PreviewServer({ - postEngine: this.postEngine, - mediaEngine: this.mediaEngine, + postEngine: cachedPostEngine, + mediaEngine: cachedMediaEngine, postMediaEngine: this.postMediaEngine, settingsEngine: { setProjectContext: () => {}, @@ -1475,12 +1546,21 @@ export class BlogGenerationEngine { }); return async (pathname: string): Promise => { - return previewServer.renderRouteForContext(pathname, { + const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); + const cached = routeHtmlCache.get(normalizedPathname); + if (cached) { + return cached; + } + + const promise = previewServer.renderRouteForContext(normalizedPathname, { projectContext, metadata, menu, maxPostsPerPage, }); + + routeHtmlCache.set(normalizedPathname, promise); + return promise; }; } diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index c93cc90..d913cea 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -586,6 +586,33 @@ describe('BlogGenerationEngine', () => { expect(result.pagesGenerated).toBe(7); }); + it('reuses shared render snapshot and media lookups across full-site routes', async () => { + const posts = [ + makePost({ id: '1', slug: 'post-1', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-15T10:00:00Z') }), + makePost({ id: '2', slug: 'post-2', categories: ['news'], tags: ['t2'], createdAt: new Date('2025-01-14T10:00:00Z') }), + makePost({ id: '3', slug: 'page-1', categories: ['page'], tags: [], 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: 1, + }, vi.fn()); + + const filteredCallCount = mockPostEngine.getPostsFiltered.mock.calls.length; + const publishedVersionCallCount = mockPostEngine.getPublishedVersion.mock.calls.length; + + expect(filteredCallCount).toBeLessThanOrEqual(20); + expect(publishedVersionCallCount).toBeLessThanOrEqual(10); + expect(mockMediaEngine.getAllMedia).toHaveBeenCalledTimes(1); + }); + it('validates sitemap against html folder without rendering missing pages', async () => { const posts = [ makePost({