From 5a2a6c9edb3d7e1c09246e60084f35c8b9b51365 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 20 Feb 2026 22:15:55 +0100 Subject: [PATCH] fix: editor-preview looks at draft again --- src/main/engine/PreviewServer.ts | 44 ++++++++++++++++--- src/main/ipc/handlers.ts | 6 ++- src/main/preload.ts | 2 +- src/main/shared/electronApi.ts | 2 +- src/renderer/components/Editor/Editor.tsx | 2 +- tests/engine/PreviewServer.test.ts | 41 +++++++++++++++++ tests/ipc/handlers.test.ts | 13 ++++++ .../EditorVisualModePersistence.test.tsx | 9 ++-- 8 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 9cc1e2d..21297a8 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -179,6 +179,8 @@ export class PreviewServer { const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme')); const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode')); + const useDraftContent = requestUrl.searchParams.get('draft') === 'true'; + const draftPostId = requestUrl.searchParams.get('postId') || undefined; const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const htmlRewriteContext = await this.buildHtmlRewriteContext(); @@ -218,7 +220,10 @@ export class PreviewServer { language, picoStylesheetHref, htmlThemeAttribute: undefined, - }, categorySettings, listExcludedCategories); + }, categorySettings, listExcludedCategories, { + useDraftContent, + draftPostId, + }); if (!result) { const notFoundHtml = await this.pageRenderer.renderNotFound({ page_title: '404 Not Found', @@ -244,6 +249,7 @@ export class PreviewServer { pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, categorySettings: Record, listExcludedCategories: string[], + singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, ): Promise { const routePagination = parseRoutePagination(pathname); if (!routePagination) { @@ -263,7 +269,7 @@ export class PreviewServer { const month = Number(postsYearMonthSlugMatch[2]); const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); if (month < 1 || month > 12) return null; - const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 }); + const post = await this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1 }); if (!post) return null; return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, @@ -276,7 +282,7 @@ export class PreviewServer { const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/); if (postsSlugMatch) { const slug = postsSlugMatch[1].replace(/\.html?$/i, ''); - const post = await this.findPublishedPostBySlug(slug); + const post = await this.findSinglePostBySlug(slug, singlePostOptions); if (!post) return null; return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, @@ -292,7 +298,7 @@ export class PreviewServer { const month = Number(legacyPostsYearMonthSlugMatch[2]); const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); if (month < 1 || month > 12) return null; - const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 }); + const post = await this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1 }); if (!post) return null; return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, @@ -305,7 +311,7 @@ export class PreviewServer { const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/); if (legacyPostsSlugMatch) { const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, ''); - const post = await this.findPublishedPostBySlug(slug); + const post = await this.findSinglePostBySlug(slug, singlePostOptions); if (!post) return null; return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, @@ -373,8 +379,7 @@ export class PreviewServer { const month = Number(daySlugMatch[2]); const day = Number(daySlugMatch[3]); const slug = daySlugMatch[4]; - const posts = await this.loadPostsForDay(year, month, day); - const post = posts.find((candidate) => candidate.slug === slug) || null; + const post = await this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day }); if (!post) return null; return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, @@ -510,6 +515,31 @@ export class PreviewServer { return match; } + private async findSinglePostBySlug( + slug: string, + singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, + dateFilter?: { year: number; month: number; day?: number }, + ): Promise { + if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) { + const draftCandidate = await this.postEngine.getPost(singlePostOptions.draftPostId); + if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) { + if (!dateFilter) { + return draftCandidate; + } + + const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year; + const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month; + const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day; + if (sameYear && sameMonth && sameDay) { + return draftCandidate; + } + } + } + + const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined; + return this.findPublishedPostBySlug(slug, fallbackDateFilter); + } + private async loadPostsForDay( year: number, month: number, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d67d9f0..5099187 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -346,7 +346,7 @@ export function registerIpcHandlers(): void { return engine.getPost(id); }); - safeHandle('posts:getPreviewUrl', async (_, id: string) => { + safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => { const engine = getPostEngine(); const post = await engine.getPost(id); @@ -356,6 +356,10 @@ export function registerIpcHandlers(): void { const createdAt = resolvePostCreatedAt(post); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); + if (options?.draft) { + return `http://127.0.0.1:4123${canonicalPath}?draft=true&postId=${encodeURIComponent(id)}`; + } + return `http://127.0.0.1:4123${canonicalPath}`; }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 0d70f07..1db57f1 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -53,7 +53,7 @@ export const electronAPI: ElectronAPI = { update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data), delete: (id: string) => ipcRenderer.invoke('posts:delete', id), get: (id: string) => ipcRenderer.invoke('posts:get', id), - getPreviewUrl: (id: string) => ipcRenderer.invoke('posts:getPreviewUrl', id), + getPreviewUrl: (id: string, options?: { draft?: boolean }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options), getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options), getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status), publish: (id: string) => ipcRenderer.invoke('posts:publish', id), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 9feaa5b..e9bf807 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -442,7 +442,7 @@ export interface ElectronAPI { update: (id: string, data: Partial) => Promise; delete: (id: string) => Promise; get: (id: string) => Promise; - getPreviewUrl: (id: string) => Promise; + getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise; getAll: (options?: { limit?: number; offset?: number }) => Promise; getByStatus: (status: string) => Promise; publish: (id: string) => Promise; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 7db5a2b..b386d06 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -205,7 +205,7 @@ export const PostEditor: React.FC = ({ postId }) => { let cancelled = false; setPreviewUrl(null); - window.electronAPI?.posts.getPreviewUrl(postId) + window.electronAPI?.posts.getPreviewUrl(postId, { draft: true }) .then((url) => { if (!cancelled) { setPreviewUrl(url); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 509c8bb..240502b 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -299,6 +299,47 @@ describe('PreviewServer', () => { expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"'); }); + it('serves draft content for single post route when draft query flag and postId are provided', async () => { + const publishedPost = makePost({ + id: 'post-1', + slug: 'shared-slug', + title: 'Published Title', + content: 'Published body', + status: 'published', + createdAt: new Date('2025-01-03T10:00:00.000Z'), + }); + const draftPost = makePost({ + id: 'post-2', + slug: 'shared-slug', + title: 'Draft Title', + content: 'Draft-only body', + status: 'draft', + createdAt: new Date('2025-01-03T10:00:00.000Z'), + }); + + server = new PreviewServer({ + postEngine: makeEngine([publishedPost, draftPost]), + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const publishedResponse = await fetch(`${server.getBaseUrl()}/posts/shared-slug`); + expect(publishedResponse.status).toBe(200); + const publishedHtml = await publishedResponse.text(); + expect(publishedHtml).toContain('Published Title'); + expect(publishedHtml).toContain('Published body'); + expect(publishedHtml).not.toContain('Draft-only body'); + + const draftResponse = await fetch(`${server.getBaseUrl()}/posts/shared-slug?draft=true&postId=post-2`); + expect(draftResponse.status).toBe(200); + const draftHtml = await draftResponse.text(); + expect(draftHtml).toContain('Draft Title'); + expect(draftHtml).toContain('Draft-only body'); + expect(draftHtml).not.toContain('Published body'); + }); + it('uses selected pico theme stylesheet from project metadata', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index fd04e53..b8176a7 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -765,6 +765,19 @@ describe('IPC Handlers', () => { expect(result).toBeNull(); }); + + it('should return draft preview URL when draft option is enabled', async () => { + mockPostEngine.getPost.mockResolvedValue(createMockPost({ + id: 'post-1', + slug: 'my-post', + createdAt: new Date('2026-02-16T12:00:00.000Z'), + })); + + const result = await invokeHandler('posts:getPreviewUrl', 'post-1', { draft: true }); + + expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1'); + expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post?draft=true&postId=post-1'); + }); }); describe('posts:getAll', () => { diff --git a/tests/renderer/components/EditorVisualModePersistence.test.tsx b/tests/renderer/components/EditorVisualModePersistence.test.tsx index 48ebb42..5f3a848 100644 --- a/tests/renderer/components/EditorVisualModePersistence.test.tsx +++ b/tests/renderer/components/EditorVisualModePersistence.test.tsx @@ -161,7 +161,7 @@ describe('Editor visual mode persistence', () => { (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost()); (window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles); (window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null); - (window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/2026/02/16/test-post'); + (window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/2026/02/16/test-post?draft=true&postId=post-1'); (window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles); useAppStore.setState({ @@ -202,7 +202,7 @@ describe('Editor visual mode persistence', () => { }); }); - it('uses canonical preview server URL in preview mode iframe', async () => { + it('uses editor preview HTML in preview mode iframe', async () => { const { getByTitle, container } = render(); await act(async () => { @@ -216,11 +216,12 @@ describe('Editor visual mode persistence', () => { await Promise.resolve(); }); - expect((window as any).electronAPI.posts.getPreviewUrl).toHaveBeenCalledWith('post-1'); + expect((window as any).electronAPI.posts.getPreviewUrl).toHaveBeenCalledWith('post-1', { draft: true }); const frame = container.querySelector('.editor-preview-frame') as HTMLIFrameElement | null; expect(frame).not.toBeNull(); - expect(frame?.getAttribute('src')).toBe('http://127.0.0.1:4123/2026/02/16/test-post'); + expect(frame?.getAttribute('src')).toBe('http://127.0.0.1:4123/2026/02/16/test-post?draft=true&postId=post-1'); + expect(frame?.getAttribute('srcdoc')).toBeNull(); expect(container.querySelector('.preview-content')).toBeNull(); });