Skip to content
Merged
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
15 changes: 9 additions & 6 deletions docs/04-adding-blog-post.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`
- DE: `/de/posts/YYYY/MM/DD/<slug>`
- 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/<slug>`

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/<filename>` alias in new content either — use the canonical `/posts/YYYY/MM/DD/<slug>` form.
Expand Down
3 changes: 2 additions & 1 deletion src/app/[locale]/[...rest]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
};
}

Expand Down
66 changes: 65 additions & 1 deletion src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<span class="iconify inline" data-icon="mdi-open-in-new" aria-hidden="true"></span>';
const HEADING_ANCHOR_ICON_HTML =
Expand Down Expand Up @@ -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(
/(<a\b[^>]*?\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"');
Expand Down Expand Up @@ -723,7 +784,10 @@ export async function getPostBySlug(
const contentHtml = highlightCodeBlocks(
decorateHeadlinesWithAnchors(
decorateExternalLinks(
centerStandaloneHtmlImages(processedContent.toString()),
localizeInternalLinks(
centerStandaloneHtmlImages(processedContent.toString()),
locale,
),
),
),
);
Expand Down
Loading