fix: url canonicalization now part of rendering, not preview server

This commit is contained in:
2026-02-22 08:06:45 +01:00
parent 8f983b5999
commit b437d79230
4 changed files with 108 additions and 71 deletions

View File

@@ -597,6 +597,18 @@ export function normalizePreviewHref(rawHref: string, rewriteContext: HtmlRewrit
return `${canonical ?? `/posts/${slug}`}${suffix}`; 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; return rawHref;
} }

View File

@@ -281,68 +281,6 @@ export class PreviewServer {
page, 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 === '/') { if (pagedPathname === '/') {
const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions); const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions);
return this.pageRenderer.renderPostList(result.posts, rewriteContext, { return this.pageRenderer.renderPostList(result.posts, rewriteContext, {

View File

@@ -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<string, string>([
['target-post', '/2025/02/14/target-post'],
]),
canonicalMediaPathBySourcePath: new Map<string, string>([
['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 = '<p><a href="/media/2025/02/ExAmPlE.JPG?download=1#asset">Image</a></p>';
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 = '<p><a href="//cdn.example.com/file.jpg?x=1#y">CDN</a><img src="//cdn.example.com/pic.jpg?x=1#y" alt="CDN image"></p>';
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"');
});
});

View File

@@ -219,7 +219,7 @@ describe('PreviewServer', () => {
expect(rootMenuIndex).toBeGreaterThan(rootH1Index); expect(rootMenuIndex).toBeGreaterThan(rootH1Index);
expect(rootPostListIndex).toBeGreaterThan(rootMenuIndex); 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('<h1>Hello</h1>'); const singleH1Index = singleHtml.indexOf('<h1>Hello</h1>');
const singleMenuIndex = singleHtml.indexOf('class="blog-menu"'); const singleMenuIndex = singleHtml.indexOf('class="blog-menu"');
const singleTextIndex = singleHtml.indexOf('<div class="post">'); const singleTextIndex = singleHtml.indexOf('<div class="post">');
@@ -370,7 +370,7 @@ describe('PreviewServer', () => {
await server.start(0); 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); expect(response.status).toBe(200);
const html = await response.text(); const html = await response.text();
@@ -416,12 +416,12 @@ describe('PreviewServer', () => {
await server.start(0); 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); expect(hvResponse.status).toBe(200);
const hvHtml = await hvResponse.text(); const hvHtml = await hvResponse.text();
expect(hvHtml).toContain('data-orientation="mixed-hv"'); 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); expect(diagonalResponse.status).toBe(200);
const diagonalHtml = await diagonalResponse.text(); const diagonalHtml = await diagonalResponse.text();
expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"'); expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"');
@@ -453,14 +453,14 @@ describe('PreviewServer', () => {
await server.start(0); 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); expect(publishedResponse.status).toBe(200);
const publishedHtml = await publishedResponse.text(); const publishedHtml = await publishedResponse.text();
expect(publishedHtml).toContain('Published Title'); expect(publishedHtml).toContain('Published Title');
expect(publishedHtml).toContain('Published body'); expect(publishedHtml).toContain('Published body');
expect(publishedHtml).not.toContain('Draft-only 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); expect(draftResponse.status).toBe(200);
const draftHtml = await draftResponse.text(); const draftHtml = await draftResponse.text();
expect(draftHtml).toContain('Draft Title'); expect(draftHtml).toContain('Draft Title');
@@ -1219,7 +1219,7 @@ describe('PreviewServer', () => {
expect(html).not.toContain('<title>Blog Preview</title>'); expect(html).not.toContain('<title>Blog Preview</title>');
}); });
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({ const targetBySlug = makePost({
id: 'target-1', id: 'target-1',
slug: 'target-post', slug: 'target-post',
@@ -1250,6 +1250,7 @@ describe('PreviewServer', () => {
'[Post by slug](/posts/target-post)', '[Post by slug](/posts/target-post)',
'[Post by year/month](/posts/2025/02/archive-post)', '[Post by year/month](/posts/2025/02/archive-post)',
'[Legacy post link](post/legacy-post)', '[Legacy post link](post/legacy-post)',
'[Media file link](/media/2025/02/example.jpg)',
'![Local image](media/2025/02/example.jpg)', '![Local image](media/2025/02/example.jpg)',
'[External](https://example.com/path)', '[External](https://example.com/path)',
].join('\n\n'), ].join('\n\n'),
@@ -1278,10 +1279,38 @@ describe('PreviewServer', () => {
expect(html).toContain('href="/2025/02/14/target-post"'); expect(html).toContain('href="/2025/02/14/target-post"');
expect(html).toContain('href="/2025/02/10/archive-post"'); expect(html).toContain('href="/2025/02/10/archive-post"');
expect(html).toContain('href="/2025/03/01/legacy-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('src="/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg"');
expect(html).toContain('href="https://example.com/path"'); 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 () => { it('renders gallery and photo_album macros as interactive lightbox markup in preview', async () => {
const post = makePost({ const post = makePost({
id: 'macro-1', id: 'macro-1',
@@ -1441,10 +1470,10 @@ describe('PreviewServer', () => {
expect(rootHtml).toContain('Published content only'); expect(rootHtml).toContain('Published content only');
expect(rootHtml).not.toContain('Draft content must not leak'); 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); 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); expect(draftSlugResponse.status).toBe(404);
const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text(); const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text();