Skip to content

Commit b950db3

Browse files
authored
fix: canonicalize trailing-slash urls (#845)
1 parent c6c037e commit b950db3

File tree

9 files changed

+88
-12
lines changed

9 files changed

+88
-12
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · Biome ·
7272

7373
- **Theming**: `data-theme` attribute on `<html>`, persisted to localStorage
7474
- **Static export**: `output: 'export'` for GitHub Pages—no server features
75+
- **Canonical/export URLs**: When generating absolute URLs for metadata, RSS, sitemap, or schema, match `trailingSlash: true` output (`/about/`, `/writing/post-slug/`) instead of non-canonical no-slash variants; file-like routes such as `/feed.xml` and `/sitemap.xml` stay file-like
7576
- **Theme images**: Use `ThemePortrait` component for light/dark variants
7677
- **Profile copy**: Keep role/bio updates in sync across `src/components/Template/Hero.tsx`, `app/layout.tsx` metadata, `src/data/about.ts`, and `src/data/resume/work.ts` so homepage copy, SEO, schema, and resume stay aligned
7778
- **Long-form markdown pages**: Prefer a dedicated renderer component that can parse markdown into semantic sections instead of styling raw headings globally; if `markdown-to-jsx` causes dev/runtime issues in App Router, a `'use client'` boundary may still be required even without hooks

app/__tests__/sitemap.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { SITE_URL } from '@/lib/utils';
4+
import sitemap from '../sitemap';
5+
6+
describe('sitemap', () => {
7+
it('uses trailing slashes for exported page routes', () => {
8+
const entries = sitemap();
9+
10+
expect(entries).toEqual(
11+
expect.arrayContaining([
12+
expect.objectContaining({ url: `${SITE_URL}/about/` }),
13+
expect.objectContaining({ url: `${SITE_URL}/resume/` }),
14+
expect.objectContaining({ url: `${SITE_URL}/projects/` }),
15+
expect.objectContaining({ url: `${SITE_URL}/writing/` }),
16+
expect.objectContaining({ url: `${SITE_URL}/stats/` }),
17+
expect.objectContaining({ url: `${SITE_URL}/contact/` }),
18+
]),
19+
);
20+
});
21+
22+
it('uses trailing slashes for post routes', () => {
23+
const entries = sitemap();
24+
const postEntries = entries.filter(
25+
(entry) =>
26+
entry.url.startsWith(`${SITE_URL}/writing/`) &&
27+
entry.url !== `${SITE_URL}/writing/`,
28+
);
29+
30+
expect(postEntries.length).toBeGreaterThan(0);
31+
expect(postEntries.every((entry) => entry.url.endsWith('/'))).toBe(true);
32+
});
33+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { SITE_URL } from '@/lib/utils';
4+
5+
import { GET } from '../route';
6+
7+
describe('feed.xml route', () => {
8+
it('uses canonical trailing-slash links for writing pages', async () => {
9+
const response = await GET();
10+
const xml = await response.text();
11+
12+
expect(xml).toContain(`${SITE_URL}/writing/`);
13+
expect(xml).toContain(`${SITE_URL}/writing/claude-code-outage/`);
14+
expect(xml).toContain(`${SITE_URL}/writing/eurostar-chatbot-analysis/`);
15+
expect(xml).toContain(`${SITE_URL}/writing/shipping-with-claude-code/`);
16+
});
17+
18+
it('keeps the feed self link file-like', async () => {
19+
const response = await GET();
20+
const xml = await response.text();
21+
22+
expect(xml).toContain(`${SITE_URL}/feed.xml`);
23+
expect(xml).not.toContain(`${SITE_URL}/feed.xml/`);
24+
});
25+
});

app/feed.xml/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export async function GET() {
3131
const internalPosts = getAllPosts();
3232
const internalItems: FeedItem[] = internalPosts.map((post) => ({
3333
title: post.title,
34-
url: `${SITE_URL}/writing/${post.slug}`,
34+
url: `${SITE_URL}/writing/${post.slug}/`,
3535
date: post.date,
3636
description: post.description,
3737
}));
@@ -68,7 +68,7 @@ export async function GET() {
6868
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
6969
<channel>
7070
<title>Michael D'Angelo - Writing</title>
71-
<link>${SITE_URL}/writing</link>
71+
<link>${SITE_URL}/writing/</link>
7272
<description>Articles on AI security, LLM red teaming, and trust &amp; safety by Michael D'Angelo.</description>
7373
<language>en-us</language>
7474
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>

app/sitemap.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
1111
// Generate entries for blog posts
1212
const posts = getAllPosts();
1313
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
14-
url: `${SITE_URL}/writing/${post.slug}`,
14+
url: `${SITE_URL}/writing/${post.slug}/`,
1515
lastModified: new Date(post.date),
1616
changeFrequency: 'monthly',
1717
priority: 0.6,
@@ -25,37 +25,37 @@ export default function sitemap(): MetadataRoute.Sitemap {
2525
priority: 1,
2626
},
2727
{
28-
url: `${SITE_URL}/about`,
28+
url: `${SITE_URL}/about/`,
2929
lastModified: currentDate,
3030
changeFrequency: 'monthly',
3131
priority: 0.8,
3232
},
3333
{
34-
url: `${SITE_URL}/resume`,
34+
url: `${SITE_URL}/resume/`,
3535
lastModified: currentDate,
3636
changeFrequency: 'monthly',
3737
priority: 0.8,
3838
},
3939
{
40-
url: `${SITE_URL}/projects`,
40+
url: `${SITE_URL}/projects/`,
4141
lastModified: currentDate,
4242
changeFrequency: 'monthly',
4343
priority: 0.8,
4444
},
4545
{
46-
url: `${SITE_URL}/writing`,
46+
url: `${SITE_URL}/writing/`,
4747
lastModified: currentDate,
4848
changeFrequency: 'weekly',
4949
priority: 0.8,
5050
},
5151
{
52-
url: `${SITE_URL}/stats`,
52+
url: `${SITE_URL}/stats/`,
5353
lastModified: currentDate,
5454
changeFrequency: 'weekly',
5555
priority: 0.5,
5656
},
5757
{
58-
url: `${SITE_URL}/contact`,
58+
url: `${SITE_URL}/contact/`,
5959
lastModified: currentDate,
6060
changeFrequency: 'yearly',
6161
priority: 0.5,

app/writing/[slug]/page.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { SITE_URL } from '@/lib/utils';
4+
5+
import { generateMetadata } from './page';
6+
7+
describe('writing post metadata', () => {
8+
it('uses a trailing-slash canonical URL for posts', async () => {
9+
const metadata = await generateMetadata({
10+
params: Promise.resolve({ slug: 'claude-code-outage' }),
11+
});
12+
13+
expect(metadata.openGraph?.url).toBe(
14+
`${SITE_URL}/writing/claude-code-outage/`,
15+
);
16+
});
17+
});

app/writing/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function generateMetadata({
2828
};
2929
}
3030

31-
const url = `${SITE_URL}/writing/${post.slug}`;
31+
const url = `${SITE_URL}/writing/${post.slug}/`;
3232

3333
return {
3434
title: post.title,

src/components/Schema/ArticleSchema.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ interface ArticleSchemaProps {
77
}
88

99
export default function ArticleSchema({ post }: ArticleSchemaProps) {
10-
const articleUrl = `${SITE_URL}/writing/${post.slug}`;
10+
const articleUrl = `${SITE_URL}/writing/${post.slug}/`;
1111

1212
const authorImage = `${SITE_URL}/images/me.jpg`;
1313

src/components/Schema/__tests__/ArticleSchema.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('ArticleSchema', () => {
5757
);
5858
const data = JSON.parse(script?.innerHTML || '{}');
5959

60-
const expectedUrl = `${SITE_URL}/writing/${mockPost.slug}`;
60+
const expectedUrl = `${SITE_URL}/writing/${mockPost.slug}/`;
6161
expect(data.url).toBe(expectedUrl);
6262
expect(data.mainEntityOfPage['@id']).toBe(expectedUrl);
6363
});

0 commit comments

Comments
 (0)