diff --git a/docs/04-adding-blog-post.md b/docs/04-adding-blog-post.md index b6dacd94..c97079a0 100644 --- a/docs/04-adding-blog-post.md +++ b/docs/04-adding-blog-post.md @@ -98,12 +98,15 @@ Write markdown below the front matter. Keep heading structure consistent between Internal link rules: -- Standard pages: - - EN: `/about`, `/contact` - - DE: `/de/about`, `/de/contact` -- Blog posts: - - EN: `/posts/YYYY/MM/DD/` - - DE: `/de/posts/YYYY/MM/DD/` +- Write internal links **locale-neutral**, without the `/de` prefix — the renderer adds the + prefix of the post's locale automatically (see `localizeInternalLinks()` in + [src/lib/markdown.ts](../src/lib/markdown.ts)). +- Standard pages: `/about`, `/contact` +- Blog posts: `/posts/YYYY/MM/DD/` + +Already existing `/de/...` links keep working — the rewriter never double-prefixes. External +links, anchors (`#…`), `mailto:`/`tel:` and asset paths (`/images/foo.png`) stay untouched. +A link to a post that only exists in English also stays unprefixed so it does not 404. Do not use `/blog/...` in new content. The active route is `/posts/...`. Do not link to the legacy `/posts/` alias in new content either — use the canonical `/posts/YYYY/MM/DD/` form. diff --git a/src/app/[locale]/[...rest]/page.tsx b/src/app/[locale]/[...rest]/page.tsx index 385e7e6a..a2b51f13 100644 --- a/src/app/[locale]/[...rest]/page.tsx +++ b/src/app/[locale]/[...rest]/page.tsx @@ -6,6 +6,7 @@ import { notFound, redirect } from 'next/navigation'; import { remark } from 'remark'; import remarkGfm from 'remark-gfm'; import html from 'remark-html'; +import { localizeInternalLinks } from '@/lib/markdown'; import { transformHugoShortcodes } from '@/lib/remark-hugo-shortcodes'; type CatchAllPageProps = { @@ -95,7 +96,7 @@ async function loadPageData( title: typeof data.title === 'string' ? data.title : 'Open Elements', description: typeof data.description === 'string' ? data.description : undefined, - contentHtml: processedContent.toString(), + contentHtml: localizeInternalLinks(processedContent.toString(), locale), }; } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 3957d4dd..4f501995 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -39,6 +39,11 @@ const INTERNAL_HOSTNAMES = new Set([ '::1', ]); +const DEFAULT_LOCALE = 'en'; +const LOCALE_PATH_PREFIXES = new Set(['en', 'de']); +// Root-relative targets that end in a file extension are assets, not routes. +const ASSET_PATH_PATTERN = /\.[a-z0-9]{2,5}$/i; + const EXTERNAL_LINK_ICON_HTML = ''; const HEADING_ANCHOR_ICON_HTML = @@ -340,6 +345,62 @@ function isExternalContentLink(href: string): boolean { } } +function localizeInternalHref(href: string, locale: string): string { + const normalizedHref = href.trim(); + + // Only root-relative links are locale-scoped; protocol-relative URLs are external. + if (!normalizedHref.startsWith('/') || normalizedHref.startsWith('//')) { + return href; + } + + const [, pathname, suffix] = /^([^?#]*)([\s\S]*)$/.exec(normalizedHref) ?? []; + + if (pathname === undefined) { + return href; + } + + const firstSegment = pathname.split('/')[1] ?? ''; + + if (LOCALE_PATH_PREFIXES.has(firstSegment)) { + return href; + } + + if (ASSET_PATH_PATTERN.test(pathname)) { + return href; + } + + const normalizedPathname = pathname.replace(/\/+$/, ''); + + // A post that only exists in the default locale must stay unprefixed, otherwise + // the localized URL would 404. + const postSlugPath = /^\/posts\/(.+)$/.exec(normalizedPathname)?.[1]; + + if (postSlugPath && !postExistsForLocale(postSlugPath, locale)) { + return href; + } + + return `/${locale}${normalizedPathname}${suffix ?? ''}`; +} + +/** + * Rewrite locale-neutral internal links so they resolve within the rendered locale. + * Authors write `/posts/2025/12/15/foo`; a German page renders `/de/posts/2025/12/15/foo`. + */ +export function localizeInternalLinks( + contentHtml: string, + locale: string, +): string { + if (locale === DEFAULT_LOCALE) { + return contentHtml; + } + + return contentHtml.replace( + /(]*?\bhref=)(["'])(.*?)\2/gi, + (_fullMatch, beforeHref: string, quote: string, href: string) => + `${beforeHref}${quote}${localizeInternalHref(href, locale)}${quote}`, + ); +} + function ensureBlankTarget(attributes: string): string { if (/\btarget\s*=/i.test(attributes)) { return attributes.replace(/\btarget\s*=\s*(["']).*?\1/i, 'target="_blank"'); @@ -723,7 +784,10 @@ export async function getPostBySlug( const contentHtml = highlightCodeBlocks( decorateHeadlinesWithAnchors( decorateExternalLinks( - centerStandaloneHtmlImages(processedContent.toString()), + localizeInternalLinks( + centerStandaloneHtmlImages(processedContent.toString()), + locale, + ), ), ), );