fix: url canonicalization now part of rendering, not preview server
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
58
tests/engine/PageRenderer.rewrite.test.ts
Normal file
58
tests/engine/PageRenderer.rewrite.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)',
|
||||||
'',
|
'',
|
||||||
'[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();
|
||||||
|
|||||||
Reference in New Issue
Block a user