Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,59 @@ function decorateExternalLinks(contentHtml: string): string {
);
}

function rewriteInternalLinks(contentHtml: string, locale: string): string {
return contentHtml.replace(
/<a\b([^>]*?)href=(["'])(.*?)\2([^>]*)>([\s\S]*?)<\/a>/gi,
(
fullMatch,
attributesBeforeHref: string,
quote: string,
href: string,
attributesAfterHref: string,
linkContent: string,
) => {
const trimmedHref = href.trim();

// Only process root-relative paths (starting with '/' but not '//')
if (!trimmedHref.startsWith('/') || trimmedHref.startsWith('//')) {
return fullMatch;
}

// Leave asset paths untouched (path segment containing a file extension)
const pathPart = trimmedHref.split(/[?#]/)[0];
const lastSegment = pathPart.split('/').pop() || '';
if (lastSegment.includes('.')) {
return fullMatch;
}

// Check if it already has a locale prefix (e.g. /de/ or /de)
const hasLocalePrefix = /^\/(de|en)(?:[\/?#]|$)/.test(trimmedHref);

// Normalize trailing slash
let normalizedHref = trimmedHref;
const match = trimmedHref.match(/^([^?#]*)(.*)$/);
if (match) {
let pPart = match[1];
const queryAndHash = match[2];
if (pPart !== '/' && pPart.endsWith('/')) {
pPart = pPart.slice(0, -1);
}
normalizedHref = pPart + queryAndHash;
}

// Prepend locale prefix if needed
let newHref = normalizedHref;
if (!hasLocalePrefix && locale !== 'en') {
const prefix = `/${locale}`;
newHref =
normalizedHref === '/' ? prefix : `${prefix}${normalizedHref}`;
}

return `<a${attributesBeforeHref}href=${quote}${newHref}${quote}${attributesAfterHref}>${linkContent}</a>`;
},
);
}

const STANDALONE_IMAGE_STYLE =
'display: block; max-width: 100%; height: auto; margin-left: auto; margin-right: auto;';

Expand Down Expand Up @@ -722,8 +775,11 @@ export async function getPostBySlug(
.process(transformedContent);
const contentHtml = highlightCodeBlocks(
decorateHeadlinesWithAnchors(
decorateExternalLinks(
centerStandaloneHtmlImages(processedContent.toString()),
rewriteInternalLinks(
decorateExternalLinks(
centerStandaloneHtmlImages(processedContent.toString()),
),
locale,
),
),
);
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/internal-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';

test.describe('Locale-aware internal link rewriter', () => {
test('rewrites internal links in a German post to include /de prefix and normalizes trailing slashes', async ({ page }) => {
// Navigate to a German post
await page.goto('/de/posts/2025-12-15-cra');

const articleBody = page.locator('main .prose').first();

// /contact should be rewritten to /de/contact
const contactLink = articleBody.locator('a[href="/de/contact"]').first();
await expect(contactLink).toBeVisible();

// /newsletter should be rewritten to /de/newsletter
const newsletterLink = articleBody.locator('a[href="/de/newsletter"]').first();
await expect(newsletterLink).toBeVisible();

// External links should NOT be rewritten
const externalLink = articleBody.locator('a[href^="https://eur-lex.europa.eu"]');
await expect(externalLink).toBeVisible();
await expect(externalLink).not.toHaveAttribute('href', /^\/de/);
});

test('rewrites shortcode relref links in a German post to include /de prefix', async ({ page }) => {
// Navigate to the German review 2025 post which contains a relref to the 2024 post
await page.goto('/de/posts/2026-02-10-review-2025');

const articleBody = page.locator('main .prose').first();

// The relref posts/2025-01-16-open-elements-in-2024 should be rewritten to /de/posts/2025-01-16-open-elements-in-2024
const relrefLink = articleBody.locator('a[href="/de/posts/2025-01-16-open-elements-in-2024"]');
await expect(relrefLink).toBeVisible();
});

test('does not prefix internal links in an English post', async ({ page }) => {
// Navigate to the English review 2025 post
await page.goto('/posts/2026-02-10-review-2025');

const articleBody = page.locator('main .prose').first();

// In English post, relref should remain unprefixed /posts/2025-01-16-open-elements-in-2024
const relrefLink = articleBody.locator('a[href="/posts/2025-01-16-open-elements-in-2024"]');
await expect(relrefLink).toBeVisible();
});
});
Loading