diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index b0b5d0d..f72af50 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -597,6 +597,18 @@ export function normalizePreviewHref(rawHref: string, rewriteContext: HtmlRewrit return `${canonical ?? `/posts/${slug}`}${suffix}`; } + const mediaMatch = pathPart.match(/^\/?media\/(\d{4})\/(\d{2})\/([^\s]+)$/i); + if (mediaMatch) { + const [, year, month, filename] = mediaMatch; + const sourceKey = `media/${year}/${month}/${filename}`.toLowerCase(); + const canonicalPath = rewriteContext.canonicalMediaPathBySourcePath.get(sourceKey); + if (canonicalPath) { + return `${canonicalPath}${suffix}`; + } + + return `/media/${year}/${month}/${filename}${suffix}`; + } + return rawHref; } diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index ee01619..84b7c27 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -281,68 +281,6 @@ export class PreviewServer { page, }; - const postsYearMonthSlugMatch = pagedPathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/); - if (postsYearMonthSlugMatch) { - const year = Number(postsYearMonthSlugMatch[1]); - const month = Number(postsYearMonthSlugMatch[2]); - const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); - if (month < 1 || month > 12) return null; - 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, - language: pageContext.language, - menu_items: pageContext.menuItems, - pico_stylesheet_href: pageContext.picoStylesheetHref, - html_theme_attribute: pageContext.htmlThemeAttribute, - }, this.postEngine); - } - - const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/); - if (postsSlugMatch) { - const slug = postsSlugMatch[1].replace(/\.html?$/i, ''); - const post = await this.findSinglePostBySlug(slug, singlePostOptions); - if (!post) return null; - return this.pageRenderer.renderSinglePost(post, rewriteContext, { - page_title: pageContext.pageTitle, - language: pageContext.language, - menu_items: pageContext.menuItems, - pico_stylesheet_href: pageContext.picoStylesheetHref, - html_theme_attribute: pageContext.htmlThemeAttribute, - }, this.postEngine); - } - - const legacyPostsYearMonthSlugMatch = pagedPathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/); - if (legacyPostsYearMonthSlugMatch) { - const year = Number(legacyPostsYearMonthSlugMatch[1]); - const month = Number(legacyPostsYearMonthSlugMatch[2]); - const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); - if (month < 1 || month > 12) return null; - 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, - language: pageContext.language, - menu_items: pageContext.menuItems, - pico_stylesheet_href: pageContext.picoStylesheetHref, - html_theme_attribute: pageContext.htmlThemeAttribute, - }, this.postEngine); - } - - const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/); - if (legacyPostsSlugMatch) { - const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, ''); - const post = await this.findSinglePostBySlug(slug, singlePostOptions); - if (!post) return null; - return this.pageRenderer.renderSinglePost(post, rewriteContext, { - page_title: pageContext.pageTitle, - language: pageContext.language, - menu_items: pageContext.menuItems, - pico_stylesheet_href: pageContext.picoStylesheetHref, - html_theme_attribute: pageContext.htmlThemeAttribute, - }, this.postEngine); - } - if (pagedPathname === '/') { const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions); return this.pageRenderer.renderPostList(result.posts, rewriteContext, { diff --git a/tests/engine/PageRenderer.rewrite.test.ts b/tests/engine/PageRenderer.rewrite.test.ts new file mode 100644 index 0000000..5b7166d --- /dev/null +++ b/tests/engine/PageRenderer.rewrite.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePreviewHref, rewriteRenderedHtmlUrls, type HtmlRewriteContext } from '../../src/main/engine/PageRenderer'; + +function makeRewriteContext(): HtmlRewriteContext { + return { + canonicalPostPathBySlug: new Map([ + ['target-post', '/2025/02/14/target-post'], + ]), + canonicalMediaPathBySourcePath: new Map([ + ['media/2025/02/example.jpg', '/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg'], + ]), + }; +} + +describe('PageRenderer URL rewriting', () => { + it('rewrites post alias URLs with .html and preserves query/hash suffixes', () => { + const rewriteContext = makeRewriteContext(); + + const rewrittenByLegacyPost = normalizePreviewHref('/post/target-post.html?draft=true#preview', rewriteContext); + expect(rewrittenByLegacyPost).toBe('/2025/02/14/target-post?draft=true#preview'); + + const rewrittenByLegacyPosts = normalizePreviewHref('/posts/2025/2/target-post.html?foo=bar#frag', rewriteContext); + expect(rewrittenByLegacyPosts).toBe('/2025/02/14/target-post?foo=bar#frag'); + }); + + it('normalizes canonical day-route URLs with .html to zero-padded canonical form', () => { + const rewriteContext = makeRewriteContext(); + + const rewritten = normalizePreviewHref('/2025/2/3/target-post.html?x=1#y', rewriteContext); + expect(rewritten).toBe('/2025/02/03/target-post?x=1#y'); + }); + + it('rewrites media alias links in href using original filename and mixed case', () => { + const rewriteContext = makeRewriteContext(); + + const html = '

Image

'; + const rewrittenHtml = rewriteRenderedHtmlUrls(html, rewriteContext); + + expect(rewrittenHtml).toContain('href="/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg?download=1#asset"'); + }); + + it('keeps unknown internal media links stable while preserving suffixes', () => { + const rewriteContext = makeRewriteContext(); + + const rewritten = normalizePreviewHref('/media/2025/02/unknown-file.jpg?raw=1#top', rewriteContext); + expect(rewritten).toBe('/media/2025/02/unknown-file.jpg?raw=1#top'); + }); + + it('does not rewrite protocol-relative URLs', () => { + const rewriteContext = makeRewriteContext(); + + const html = '

CDNCDN image

'; + const rewrittenHtml = rewriteRenderedHtmlUrls(html, rewriteContext); + + expect(rewrittenHtml).toContain('href="//cdn.example.com/file.jpg?x=1#y"'); + expect(rewrittenHtml).toContain('src="//cdn.example.com/pic.jpg?x=1#y"'); + }); +}); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 7e18c62..b0dca04 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -219,7 +219,7 @@ describe('PreviewServer', () => { expect(rootMenuIndex).toBeGreaterThan(rootH1Index); expect(rootPostListIndex).toBeGreaterThan(rootMenuIndex); - const singleHtml = await (await fetch(`${server.getBaseUrl()}/posts/hello`)).text(); + const singleHtml = await (await fetch(`${server.getBaseUrl()}/2025/01/03/hello`)).text(); const singleH1Index = singleHtml.indexOf('

Hello

'); const singleMenuIndex = singleHtml.indexOf('class="blog-menu"'); const singleTextIndex = singleHtml.indexOf('
'); @@ -370,7 +370,7 @@ describe('PreviewServer', () => { await server.start(0); - const response = await fetch(`${server.getBaseUrl()}/posts/tag-cloud-source`); + const response = await fetch(`${server.getBaseUrl()}/2025/01/02/tag-cloud-source`); expect(response.status).toBe(200); const html = await response.text(); @@ -416,12 +416,12 @@ describe('PreviewServer', () => { await server.start(0); - const hvResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-hv`); + const hvResponse = await fetch(`${server.getBaseUrl()}/2025/01/02/orientation-hv`); expect(hvResponse.status).toBe(200); const hvHtml = await hvResponse.text(); expect(hvHtml).toContain('data-orientation="mixed-hv"'); - const diagonalResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-diagonal`); + const diagonalResponse = await fetch(`${server.getBaseUrl()}/2025/01/02/orientation-diagonal`); expect(diagonalResponse.status).toBe(200); const diagonalHtml = await diagonalResponse.text(); expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"'); @@ -453,14 +453,14 @@ describe('PreviewServer', () => { await server.start(0); - const publishedResponse = await fetch(`${server.getBaseUrl()}/posts/shared-slug`); + const publishedResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/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`); + const draftResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug?draft=true&postId=post-2`); expect(draftResponse.status).toBe(200); const draftHtml = await draftResponse.text(); expect(draftHtml).toContain('Draft Title'); @@ -1219,7 +1219,7 @@ describe('PreviewServer', () => { expect(html).not.toContain('Blog Preview'); }); - it('rewrites supported markdown links to preview-safe URLs while leaving external links unchanged', async () => { + it('rewrites internal markdown links to canonical URLs while leaving external links unchanged', async () => { const targetBySlug = makePost({ id: 'target-1', slug: 'target-post', @@ -1250,6 +1250,7 @@ describe('PreviewServer', () => { '[Post by slug](/posts/target-post)', '[Post by year/month](/posts/2025/02/archive-post)', '[Legacy post link](post/legacy-post)', + '[Media file link](/media/2025/02/example.jpg)', '![Local image](media/2025/02/example.jpg)', '[External](https://example.com/path)', ].join('\n\n'), @@ -1278,10 +1279,38 @@ describe('PreviewServer', () => { expect(html).toContain('href="/2025/02/14/target-post"'); expect(html).toContain('href="/2025/02/10/archive-post"'); expect(html).toContain('href="/2025/03/01/legacy-post"'); + expect(html).toContain('href="/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg"'); expect(html).toContain('src="/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg"'); expect(html).toContain('href="https://example.com/path"'); }); + it('does not resolve legacy post URL routes', async () => { + const post = makePost({ + id: 'legacy-route-1', + slug: 'legacy-route-target', + title: 'Legacy Route Target', + createdAt: new Date('2025-04-05T10:00:00.000Z'), + content: '# Legacy route target', + }); + + server = new PreviewServer({ + postEngine: makeEngine([post]), + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const canonicalResponse = await fetch(`${server.getBaseUrl()}/2025/04/05/legacy-route-target`); + expect(canonicalResponse.status).toBe(200); + + const legacyPostsResponse = await fetch(`${server.getBaseUrl()}/posts/legacy-route-target`); + expect(legacyPostsResponse.status).toBe(404); + + const legacyPostResponse = await fetch(`${server.getBaseUrl()}/post/legacy-route-target`); + expect(legacyPostResponse.status).toBe(404); + }); + it('renders gallery and photo_album macros as interactive lightbox markup in preview', async () => { const post = makePost({ id: 'macro-1', @@ -1441,10 +1470,10 @@ describe('PreviewServer', () => { expect(rootHtml).toContain('Published content only'); expect(rootHtml).not.toContain('Draft content must not leak'); - const publishedSlugResponse = await fetch(`${server.getBaseUrl()}/posts/published-slug/`); + const publishedSlugResponse = await fetch(`${server.getBaseUrl()}/2025/02/14/published-slug/`); expect(publishedSlugResponse.status).toBe(200); - const draftSlugResponse = await fetch(`${server.getBaseUrl()}/posts/draft-slug/`); + const draftSlugResponse = await fetch(`${server.getBaseUrl()}/2025/02/14/draft-slug/`); expect(draftSlugResponse.status).toBe(404); const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text();