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

@@ -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(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 singleMenuIndex = singleHtml.indexOf('class="blog-menu"');
const singleTextIndex = singleHtml.indexOf('<div class="post">');
@@ -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('<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({
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();