diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 24cf85c..62bce11 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -1049,6 +1049,36 @@ export class PostEngine extends EventEmitter { return !!(dbPost && dbPost.filePath && dbPost.filePath !== ''); } + async getPublishedVersion(id: string): Promise { + const db = getDatabase().getLocal(); + const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); + + if (!dbPost || !dbPost.filePath) { + return null; + } + + const fileData = await this.readPostFile(dbPost.filePath); + if (!fileData) { + return null; + } + + return { + id: dbPost.id, + projectId: dbPost.projectId, + title: fileData.title, + slug: fileData.slug, + excerpt: fileData.excerpt, + content: fileData.content, + status: 'published', + author: fileData.author, + createdAt: fileData.createdAt, + updatedAt: fileData.updatedAt, + publishedAt: fileData.publishedAt ?? dbPost.publishedAt ?? undefined, + tags: fileData.tags, + categories: fileData.categories, + }; + } + /** * Rebuild the FTS index for all posts in the current project. * Call this after changing the search language or after migration. diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index f916838..139228c 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -17,6 +17,8 @@ interface ActiveProjectContext { interface PostEngineContract { getPostsFiltered: (filter: PostFilter) => Promise; getPost: (id: string) => Promise; + hasPublishedVersion: (id: string) => Promise; + getPublishedVersion: (id: string) => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; } @@ -472,21 +474,21 @@ export class PreviewServer { } if (pathname === '/') { - const posts = await this.loadPublishedPosts({ status: 'published' }, maxPostsPerPage); + const posts = await this.loadPublishedSnapshots({ status: 'published' }, maxPostsPerPage); return this.renderPostList(posts, rewriteContext); } const tagMatch = pathname.match(/^\/tag\/([^/]+)$/); if (tagMatch) { const tag = tagMatch[1]; - const posts = await this.loadPublishedPosts({ status: 'published', tags: [tag] }, maxPostsPerPage); + const posts = await this.loadPublishedSnapshots({ status: 'published', tags: [tag] }, maxPostsPerPage); return this.renderPostList(posts, rewriteContext); } const categoryMatch = pathname.match(/^\/category\/([^/]+)$/); if (categoryMatch) { const category = categoryMatch[1]; - const posts = await this.loadPublishedPosts({ status: 'published', categories: [category] }, maxPostsPerPage); + const posts = await this.loadPublishedSnapshots({ status: 'published', categories: [category] }, maxPostsPerPage); return this.renderPostList(posts, rewriteContext); } @@ -516,21 +518,21 @@ export class PreviewServer { const year = Number(monthMatch[1]); const month = Number(monthMatch[2]); if (month < 1 || month > 12) return null; - const posts = await this.loadPublishedPosts({ status: 'published', year, month: month - 1 }, maxPostsPerPage); + const posts = await this.loadPublishedSnapshots({ status: 'published', year, month: month - 1 }, maxPostsPerPage); return this.renderPostList(posts, rewriteContext); } const yearMatch = pathname.match(/^\/(\d{4})$/); if (yearMatch) { const year = Number(yearMatch[1]); - const posts = await this.loadPublishedPosts({ status: 'published', year }, maxPostsPerPage); + const posts = await this.loadPublishedSnapshots({ status: 'published', year }, maxPostsPerPage); return this.renderPostList(posts, rewriteContext); } const pageSlugMatch = pathname.match(/^\/([^/]+)$/); if (pageSlugMatch) { const slug = pageSlugMatch[1]; - const pages = await this.loadPublishedPosts({ status: 'published', categories: ['page'] }, maxPostsPerPage); + const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, maxPostsPerPage); const page = pages.find((candidate) => candidate.slug === slug) || null; if (!page) return null; return this.renderPostList([page], rewriteContext); @@ -543,15 +545,14 @@ export class PreviewServer { if (!slug) return null; const filter: PostFilter = { - status: 'published', ...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}), }; - const candidates = await this.postEngine.getPostsFiltered(filter); + const candidates = await this.loadPublishedSnapshots(filter); const match = candidates.find((candidate) => candidate.slug === slug); if (!match) return null; - return (await this.postEngine.getPost(match.id)) ?? match; + return match; } private async loadPostsForDay(year: number, month: number, day: number, maxPostsPerPage: number): Promise { @@ -562,7 +563,7 @@ export class PreviewServer { const startDate = new Date(year, month - 1, day, 0, 0, 0, 0); const endDate = new Date(year, month - 1, day, 23, 59, 59, 999); - const posts = await this.loadPublishedPosts({ + const posts = await this.loadPublishedSnapshots({ status: 'published', startDate, endDate, @@ -576,27 +577,83 @@ export class PreviewServer { }); } - private async loadPublishedPosts(filter: PostFilter, maxPostsPerPage: number): Promise { - const posts = await this.postEngine.getPostsFiltered(filter); - const limited = posts.slice(0, maxPostsPerPage); + private buildSnapshotBaseFilter(filter: PostFilter): PostFilter { + const baseFilter: PostFilter = {}; - const withContent = await Promise.all( - limited.map(async (post) => { - const fullPost = await this.postEngine.getPost(post.id); - return fullPost ?? post; - }) - ); + if (filter.startDate) baseFilter.startDate = filter.startDate; + if (filter.endDate) baseFilter.endDate = filter.endDate; + if (filter.year !== undefined) baseFilter.year = filter.year; + if (filter.month !== undefined) baseFilter.month = filter.month; - return withContent; + return baseFilter; + } + + private async toPublishedSnapshot(post: PostData): Promise { + if (post.status === 'published') { + return post; + } + + if (post.status === 'draft') { + return await this.postEngine.getPublishedVersion(post.id); + } + + return null; + } + + private async loadPublishedSnapshots(filter: PostFilter, maxPostsPerPage?: number): Promise { + if (filter.status && filter.status !== 'published') { + return []; + } + + const baseFilter = this.buildSnapshotBaseFilter(filter); + const publishedCandidates = await this.postEngine.getPostsFiltered({ + ...baseFilter, + status: 'published', + }); + const draftCandidates = await this.postEngine.getPostsFiltered({ + ...baseFilter, + status: 'draft', + }); + + const snapshotCandidates = await Promise.all([ + ...publishedCandidates.map((post) => this.toPublishedSnapshot(post)), + ...draftCandidates.map((post) => this.toPublishedSnapshot(post)), + ]); + + let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null); + + if (filter.tags && filter.tags.length > 0) { + snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag))); + } + + if (filter.categories && filter.categories.length > 0) { + snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category))); + } + + snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + if (typeof maxPostsPerPage === 'number') { + return snapshots.slice(0, maxPostsPerPage); + } + + return snapshots; } private async renderPostList(posts: PostData[], rewriteContext: HtmlRewriteContext): Promise { - const rendered = await Promise.all(posts.map((post) => renderPostHtml(post, rewriteContext))); + const renderablePosts = await Promise.all(posts.map(async (post) => { + if (post.status === 'published' && !post.content) { + const fullPost = await this.postEngine.getPost(post.id); + return fullPost ?? post; + } + return post; + })); + + const rendered = await Promise.all(renderablePosts.map((post) => renderPostHtml(post, rewriteContext))); return rendered.join('\n'); } private async buildHtmlRewriteContext(): Promise { - const publishedPosts = await this.postEngine.getPostsFiltered({ status: 'published' }); + const publishedPosts = await this.loadPublishedSnapshots({ status: 'published' }); const canonicalPostPathBySlug = new Map(); for (const post of publishedPosts) { diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index 36c185a..381476b 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2136,6 +2136,77 @@ Published content`); }); }); + describe('getPublishedVersion', () => { + it('should return null when post has no published file', async () => { + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: 'draft-only-id', + projectId: 'default', + filePath: '', + }), + }); + return chain; + }); + + const result = await postEngine.getPublishedVersion('draft-only-id'); + expect(result).toBeNull(); + }); + + it('should return published content and metadata from filesystem snapshot', async () => { + const publishedFilePath = '/mock/published/snapshot.md'; + mockFiles.set(publishedFilePath, `--- +id: snapshot-id +projectId: default +title: Published Snapshot Title +slug: published-snapshot +status: published +createdAt: 2024-01-01T00:00:00.000Z +updatedAt: 2024-01-02T00:00:00.000Z +publishedAt: 2024-01-03T00:00:00.000Z +tags: + - published-tag +categories: + - page +--- +Published snapshot content`); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: 'snapshot-id', + projectId: 'default', + title: 'Draft title should not be used', + slug: 'draft-slug', + status: 'draft', + content: 'Draft content should not be used', + filePath: publishedFilePath, + tags: '[]', + categories: '[]', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-10T00:00:00.000Z'), + publishedAt: new Date('2024-01-03T00:00:00.000Z'), + }), + }); + return chain; + }); + + const result = await postEngine.getPublishedVersion('snapshot-id'); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('published'); + expect(result?.title).toBe('Published Snapshot Title'); + expect(result?.slug).toBe('published-snapshot'); + expect(result?.content).toBe('Published snapshot content'); + expect(result?.tags).toEqual(['published-tag']); + expect(result?.categories).toEqual(['page']); + }); + }); + describe('getAllPosts', () => { it('should return empty result when no posts exist', async () => { vi.mocked(mockLocalDb.select).mockImplementation(() => { diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 8cdefac..3d3292b 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -8,6 +8,8 @@ import { PreviewServer } from '../../src/main/engine/PreviewServer'; type PostEngineLike = { getPostsFiltered: (filter: PostFilter) => Promise; getPost: (id: string) => Promise; + hasPublishedVersion: (id: string) => Promise; + getPublishedVersion: (id: string) => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; }; @@ -43,6 +45,12 @@ function makeEngine(posts: PostData[]): PostEngineLike { return { setProjectContext: vi.fn(), + async hasPublishedVersion(): Promise { + return false; + }, + async getPublishedVersion(): Promise { + return null; + }, async getPost(id: string): Promise { return byId.get(id) ?? null; }, @@ -431,4 +439,144 @@ describe('PreviewServer', () => { const body = await response.text(); expect(body).toBe('fake-image-bytes'); }); + + it('uses published snapshot content and metadata for draft posts that have a published version', async () => { + const draftWithPublished = makePost({ + id: 'draft-1', + status: 'draft', + title: 'Draft Title', + slug: 'draft-slug', + content: '# Draft content must not leak', + tags: ['draft-tag'], + categories: ['draft-category'], + createdAt: new Date('2025-02-14T10:00:00.000Z'), + }); + + const publishedSnapshot = makePost({ + id: 'draft-1', + status: 'published', + title: 'Published Title', + slug: 'published-slug', + content: '# Published content only', + tags: ['published-tag'], + categories: ['page'], + createdAt: new Date('2025-02-14T10:00:00.000Z'), + }); + + const engine = makeEngine([draftWithPublished]); + engine.hasPublishedVersion = vi.fn(async (id: string) => id === 'draft-1'); + engine.getPublishedVersion = vi.fn(async (id: string) => (id === 'draft-1' ? publishedSnapshot : null)); + + server = new PreviewServer({ + postEngine: engine, + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text(); + expect(rootHtml).toContain('Published content only'); + expect(rootHtml).not.toContain('Draft content must not leak'); + + const publishedSlugResponse = await fetch(`${server.getBaseUrl()}/posts/published-slug/`); + expect(publishedSlugResponse.status).toBe(200); + + const draftSlugResponse = await fetch(`${server.getBaseUrl()}/posts/draft-slug/`); + expect(draftSlugResponse.status).toBe(404); + + const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text(); + expect(publishedTagHtml).toContain('Published content only'); + + const draftTagResponse = await fetch(`${server.getBaseUrl()}/tag/draft-tag/`); + expect(draftTagResponse.status).toBe(404); + const draftTagHtml = await draftTagResponse.text(); + expect(draftTagHtml).not.toContain('Published content only'); + }); + + it('discovers candidates via status-scoped DB filters for published and draft only', async () => { + const published = makePost({ id: 'pub-1', status: 'published', slug: 'pub-1', content: '# Published one' }); + const draft = makePost({ id: 'draft-1', status: 'draft', slug: 'draft-1', content: '# Draft one' }); + + const getPostsFiltered = vi.fn(async (filter: PostFilter) => { + if (filter.status === 'published') return [published]; + if (filter.status === 'draft') return [draft]; + return []; + }); + + const engine: PostEngineLike = { + setProjectContext: vi.fn(), + getPostsFiltered, + getPost: vi.fn(async (id: string) => (id === published.id ? published : draft)), + hasPublishedVersion: vi.fn(async (id: string) => id === draft.id), + getPublishedVersion: vi.fn(async (id: string) => (id === draft.id + ? makePost({ ...published, id: draft.id, slug: 'pub-draft', content: '# Published snapshot for draft' }) + : null)), + }; + + server = new PreviewServer({ + postEngine: engine, + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + const response = await fetch(`${server.getBaseUrl()}/`); + expect(response.status).toBe(200); + + const statusValues = getPostsFiltered.mock.calls.map((args) => args[0]?.status); + expect(statusValues.every((value) => value === 'published' || value === 'draft')).toBe(true); + expect(statusValues).toContain('published'); + expect(statusValues).toContain('draft'); + }); + + it('loads published filesystem content only for rendered posts', async () => { + const fullPublishedPosts = Array.from({ length: 60 }).map((_, index) => + makePost({ + id: `pub-full-${index + 1}`, + slug: `pub-full-${index + 1}`, + title: `Published Full ${index + 1}`, + content: `# Published Full ${index + 1}`, + status: 'published', + createdAt: new Date(Date.UTC(2025, 0, 1, 0, 0, index)), + }) + ); + + const summaryPublishedPosts = fullPublishedPosts.map((post) => ({ + ...post, + content: '', + })); + + const byId = new Map(fullPublishedPosts.map((post) => [post.id, post])); + const getPost = vi.fn(async (id: string) => byId.get(id) ?? null); + + const engine: PostEngineLike = { + setProjectContext: vi.fn(), + getPost, + hasPublishedVersion: vi.fn(async () => false), + getPublishedVersion: vi.fn(async () => null), + getPostsFiltered: vi.fn(async (filter: PostFilter) => { + if (filter.status === 'published') { + return summaryPublishedPosts; + } + if (filter.status === 'draft') { + return []; + } + return []; + }), + }; + + server = new PreviewServer({ + postEngine: engine, + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const response = await fetch(`${server.getBaseUrl()}/`); + expect(response.status).toBe(200); + + expect(getPost).toHaveBeenCalledTimes(50); + }); }); \ No newline at end of file