diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 3957d4dd..b6eff739 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -398,6 +398,59 @@ function decorateExternalLinks(contentHtml: string): string { ); } +function rewriteInternalLinks(contentHtml: string, locale: string): string { + return contentHtml.replace( + /]*?)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 `${linkContent}`; + }, + ); +} + const STANDALONE_IMAGE_STYLE = 'display: block; max-width: 100%; height: auto; margin-left: auto; margin-right: auto;'; @@ -722,8 +775,11 @@ export async function getPostBySlug( .process(transformedContent); const contentHtml = highlightCodeBlocks( decorateHeadlinesWithAnchors( - decorateExternalLinks( - centerStandaloneHtmlImages(processedContent.toString()), + rewriteInternalLinks( + decorateExternalLinks( + centerStandaloneHtmlImages(processedContent.toString()), + ), + locale, ), ), ); diff --git a/tests/e2e/internal-links.spec.ts b/tests/e2e/internal-links.spec.ts new file mode 100644 index 00000000..816d3221 --- /dev/null +++ b/tests/e2e/internal-links.spec.ts @@ -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(); + }); +});