fix: editor-preview looks at draft again

This commit is contained in:
2026-02-20 22:15:55 +01:00
parent 1543af6edc
commit 5a2a6c9edb
8 changed files with 104 additions and 15 deletions

View File

@@ -179,6 +179,8 @@ export class PreviewServer {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme')); const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode')); 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 appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = await this.buildHtmlRewriteContext(); const htmlRewriteContext = await this.buildHtmlRewriteContext();
@@ -218,7 +220,10 @@ export class PreviewServer {
language, language,
picoStylesheetHref, picoStylesheetHref,
htmlThemeAttribute: undefined, htmlThemeAttribute: undefined,
}, categorySettings, listExcludedCategories); }, categorySettings, listExcludedCategories, {
useDraftContent,
draftPostId,
});
if (!result) { if (!result) {
const notFoundHtml = await this.pageRenderer.renderNotFound({ const notFoundHtml = await this.pageRenderer.renderNotFound({
page_title: '404 Not Found', page_title: '404 Not Found',
@@ -244,6 +249,7 @@ export class PreviewServer {
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>, categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[], listExcludedCategories: string[],
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
): Promise<string | null> { ): Promise<string | null> {
const routePagination = parseRoutePagination(pathname); const routePagination = parseRoutePagination(pathname);
if (!routePagination) { if (!routePagination) {
@@ -263,7 +269,7 @@ export class PreviewServer {
const month = Number(postsYearMonthSlugMatch[2]); const month = Number(postsYearMonthSlugMatch[2]);
const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
if (month < 1 || month > 12) return null; 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; if (!post) return null;
return this.pageRenderer.renderSinglePost(post, rewriteContext, { return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
@@ -276,7 +282,7 @@ export class PreviewServer {
const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/); const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/);
if (postsSlugMatch) { if (postsSlugMatch) {
const slug = postsSlugMatch[1].replace(/\.html?$/i, ''); const slug = postsSlugMatch[1].replace(/\.html?$/i, '');
const post = await this.findPublishedPostBySlug(slug); const post = await this.findSinglePostBySlug(slug, singlePostOptions);
if (!post) return null; if (!post) return null;
return this.pageRenderer.renderSinglePost(post, rewriteContext, { return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
@@ -292,7 +298,7 @@ export class PreviewServer {
const month = Number(legacyPostsYearMonthSlugMatch[2]); const month = Number(legacyPostsYearMonthSlugMatch[2]);
const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
if (month < 1 || month > 12) return null; 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; if (!post) return null;
return this.pageRenderer.renderSinglePost(post, rewriteContext, { return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
@@ -305,7 +311,7 @@ export class PreviewServer {
const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/); const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/);
if (legacyPostsSlugMatch) { if (legacyPostsSlugMatch) {
const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, ''); const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, '');
const post = await this.findPublishedPostBySlug(slug); const post = await this.findSinglePostBySlug(slug, singlePostOptions);
if (!post) return null; if (!post) return null;
return this.pageRenderer.renderSinglePost(post, rewriteContext, { return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
@@ -373,8 +379,7 @@ export class PreviewServer {
const month = Number(daySlugMatch[2]); const month = Number(daySlugMatch[2]);
const day = Number(daySlugMatch[3]); const day = Number(daySlugMatch[3]);
const slug = daySlugMatch[4]; const slug = daySlugMatch[4];
const posts = await this.loadPostsForDay(year, month, day); const post = await this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
const post = posts.find((candidate) => candidate.slug === slug) || null;
if (!post) return null; if (!post) return null;
return this.pageRenderer.renderSinglePost(post, rewriteContext, { return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
@@ -510,6 +515,31 @@ export class PreviewServer {
return match; return match;
} }
private async findSinglePostBySlug(
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
dateFilter?: { year: number; month: number; day?: number },
): Promise<PostData | null> {
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( private async loadPostsForDay(
year: number, year: number,
month: number, month: number,

View File

@@ -346,7 +346,7 @@ export function registerIpcHandlers(): void {
return engine.getPost(id); return engine.getPost(id);
}); });
safeHandle('posts:getPreviewUrl', async (_, id: string) => { safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => {
const engine = getPostEngine(); const engine = getPostEngine();
const post = await engine.getPost(id); const post = await engine.getPost(id);
@@ -356,6 +356,10 @@ export function registerIpcHandlers(): void {
const createdAt = resolvePostCreatedAt(post); const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); 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}`; return `http://127.0.0.1:4123${canonicalPath}`;
}); });

View File

@@ -53,7 +53,7 @@ export const electronAPI: ElectronAPI = {
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data), update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', id), delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', 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), getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status), getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id), publish: (id: string) => ipcRenderer.invoke('posts:publish', id),

View File

@@ -442,7 +442,7 @@ export interface ElectronAPI {
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>; update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>; delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>; get: (id: string) => Promise<PostData | null>;
getPreviewUrl: (id: string) => Promise<string | null>; getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>; getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>; getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>; publish: (id: string) => Promise<PostData | null>;

View File

@@ -205,7 +205,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
let cancelled = false; let cancelled = false;
setPreviewUrl(null); setPreviewUrl(null);
window.electronAPI?.posts.getPreviewUrl(postId) window.electronAPI?.posts.getPreviewUrl(postId, { draft: true })
.then((url) => { .then((url) => {
if (!cancelled) { if (!cancelled) {
setPreviewUrl(url); setPreviewUrl(url);

View File

@@ -299,6 +299,47 @@ describe('PreviewServer', () => {
expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"'); 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 () => { it('uses selected pico theme stylesheet from project metadata', async () => {
server = new PreviewServer({ server = new PreviewServer({
postEngine: makeEngine([makePost()]), postEngine: makeEngine([makePost()]),

View File

@@ -765,6 +765,19 @@ describe('IPC Handlers', () => {
expect(result).toBeNull(); 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', () => { describe('posts:getAll', () => {

View File

@@ -161,7 +161,7 @@ describe('Editor visual mode persistence', () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost()); (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.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null); (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); (window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
useAppStore.setState({ 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(<PostEditor postId="post-1" />); const { getByTitle, container } = render(<PostEditor postId="post-1" />);
await act(async () => { await act(async () => {
@@ -216,11 +216,12 @@ describe('Editor visual mode persistence', () => {
await Promise.resolve(); 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; const frame = container.querySelector('.editor-preview-frame') as HTMLIFrameElement | null;
expect(frame).not.toBeNull(); 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(); expect(container.querySelector('.preview-content')).toBeNull();
}); });