Skip to content

Commit 6836d94

Browse files
committed
feat: enhance locale handling and markdown file loading for dynamic routes
Signed-off-by: Daniel Ntege <danientege785@gmail.com>
1 parent bda4309 commit 6836d94

4 files changed

Lines changed: 202 additions & 20 deletions

File tree

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,130 @@
1-
import { notFound } from 'next/navigation'
1+
import fs from 'fs';
2+
import path from 'path';
3+
import matter from 'gray-matter';
4+
import type { Metadata } from 'next';
5+
import { notFound, redirect } from 'next/navigation';
6+
import { remark } from 'remark';
7+
import remarkGfm from 'remark-gfm';
8+
import html from 'remark-html';
9+
import { transformHugoShortcodes } from '@/lib/remark-hugo-shortcodes';
210

3-
export default function LocaleCatchAllPage() {
4-
notFound()
11+
type CatchAllPageProps = {
12+
params: Promise<{
13+
locale: string;
14+
rest: string[];
15+
}>;
16+
};
17+
18+
const contentDirectory = path.join(process.cwd(), 'content');
19+
20+
const CONTENT_PATH_ALIASES: Record<string, string> = {
21+
'support-care-maven/status': 'support-care-maven-status',
22+
};
23+
24+
const REDIRECTS_TO_POSTS = new Set(['articles', 'categories', 'tags']);
25+
26+
function localePrefix(locale: string): string {
27+
return locale === 'de' ? '/de' : '';
28+
}
29+
30+
function maybeRedirectLegacyPath(locale: string, requestPath: string): string | null {
31+
const normalizedPath = requestPath.replace(/^\/+|\/+$/g, '');
32+
const prefix = localePrefix(locale);
33+
34+
if (normalizedPath === 'about-support-care') {
35+
return `${prefix}/support-care`;
36+
}
37+
38+
if (normalizedPath === 'employees') {
39+
return `${prefix}/about`;
40+
}
41+
42+
if (REDIRECTS_TO_POSTS.has(normalizedPath) || normalizedPath.startsWith('categories/') || normalizedPath.startsWith('tags/')) {
43+
return `${prefix}/posts`;
44+
}
45+
46+
return null;
47+
}
48+
49+
function findMarkdownFile(locale: string, requestPath: string): string | null {
50+
const normalizedPath = requestPath.replace(/^\/+|\/+$/g, '');
51+
const mappedPath = CONTENT_PATH_ALIASES[normalizedPath] || normalizedPath;
52+
const deFile = path.join(contentDirectory, mappedPath, 'index.de.md');
53+
const enFile = path.join(contentDirectory, mappedPath, 'index.md');
54+
55+
if (locale === 'de') {
56+
if (fs.existsSync(deFile)) return deFile;
57+
if (fs.existsSync(enFile)) return enFile;
58+
return null;
59+
}
60+
61+
if (fs.existsSync(enFile)) return enFile;
62+
return null;
63+
}
64+
65+
async function loadPageData(locale: string, requestPath: string): Promise<{ title: string; description?: string; contentHtml: string } | null> {
66+
const markdownFile = findMarkdownFile(locale, requestPath);
67+
68+
if (!markdownFile) {
69+
return null;
70+
}
71+
72+
const fileContents = fs.readFileSync(markdownFile, 'utf8');
73+
const { data, content } = matter(fileContents);
74+
const transformedContent = transformHugoShortcodes(content);
75+
const processedContent = await remark().use(remarkGfm).use(html, { sanitize: false }).process(transformedContent);
76+
77+
return {
78+
title: typeof data.title === 'string' ? data.title : 'Open Elements',
79+
description: typeof data.description === 'string' ? data.description : undefined,
80+
contentHtml: processedContent.toString(),
81+
};
82+
}
83+
84+
export async function generateMetadata({ params }: CatchAllPageProps): Promise<Metadata> {
85+
const { locale, rest } = await params;
86+
const requestPath = rest.join('/');
87+
88+
if (maybeRedirectLegacyPath(locale, requestPath)) {
89+
return {
90+
title: 'Open Elements',
91+
};
92+
}
93+
94+
const pageData = await loadPageData(locale, requestPath);
95+
96+
if (!pageData) {
97+
return {
98+
title: 'Page Not Found',
99+
};
100+
}
101+
102+
return {
103+
title: `${pageData.title} - Open Elements`,
104+
description: pageData.description,
105+
};
106+
}
107+
108+
export default async function LocaleCatchAllPage({ params }: CatchAllPageProps) {
109+
const { locale, rest } = await params;
110+
const requestPath = rest.join('/');
111+
112+
const redirectTarget = maybeRedirectLegacyPath(locale, requestPath);
113+
if (redirectTarget) {
114+
redirect(redirectTarget);
115+
}
116+
117+
const pageData = await loadPageData(locale, requestPath);
118+
if (!pageData) {
119+
notFound();
120+
}
121+
122+
return (
123+
<div className="container py-16 sm:py-24">
124+
<article className="prose prose-sm sm:prose-base text-blue max-w-4xl mx-auto prose-a:text-purple-700">
125+
<h1>{pageData.title}</h1>
126+
<div dangerouslySetInnerHTML={{ __html: pageData.contentHtml }} />
127+
</article>
128+
</div>
129+
);
5130
}

src/app/[locale]/posts/[...slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getPostBySlug, getAllPostSlugs } from '@/lib/markdown';
66
import teamDataEn from '@/data/en/team.json';
77
import teamDataDe from '@/data/de/team.json';
88

9-
export const dynamicParams = false;
9+
export const dynamicParams = true;
1010

1111
interface TeamMember {
1212
id: string;

src/lib/markdown.ts

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,13 @@ Prism.hooks.add('wrap', (environment) => {
116116

117117
/**
118118
* Generate a URL-friendly slug from text.
119-
* Transliterates German umlauts and common diacritics before slugifying.
119+
* Keeps locale-specific characters (e.g. umlauts) to match Hugo URLs.
120120
*/
121121
function slugify(text: string): string {
122122
return text
123123
.toLowerCase()
124-
.replace(/ä/g, 'ae')
125-
.replace(/ö/g, 'oe')
126-
.replace(/ü/g, 'ue')
127-
.replace(/ß/g, 'ss')
128-
.normalize('NFD')
129-
.replace(/[\u0300-\u036f]/g, '') // Remove remaining diacritics
130-
.replace(/[^a-z0-9]+/g, '-')
124+
.replace(/[^\p{L}\p{N}]+/gu, '-')
125+
.replace(/-+/g, '-')
131126
.replace(/^-+|-+$/g, '');
132127
}
133128

@@ -155,6 +150,49 @@ function generatePostPath(frontmatter: PostFrontmatter): string {
155150
return `${year}/${month}/${day}/${slug}`;
156151
}
157152

153+
function normalizeLegacySlugSegment(slug: string): string {
154+
return slug
155+
.toLowerCase()
156+
.replace(/[^\p{L}\p{N}]+/gu, '-')
157+
.replace(/-+/g, '-')
158+
.replace(/^-+|-+$/g, '');
159+
}
160+
161+
function isDateBasedSlugMatch(candidatePath: string, requestedPath: string): boolean {
162+
if (candidatePath === requestedPath) {
163+
return true;
164+
}
165+
166+
const candidateSegments = candidatePath.split('/');
167+
const requestedSegments = requestedPath.split('/');
168+
169+
if (candidateSegments.length !== 4 || requestedSegments.length !== 4) {
170+
return false;
171+
}
172+
173+
const candidateDatePrefix = candidateSegments.slice(0, 3).join('/');
174+
const requestedDatePrefix = requestedSegments.slice(0, 3).join('/');
175+
176+
if (candidateDatePrefix !== requestedDatePrefix) {
177+
return false;
178+
}
179+
180+
return normalizeLegacySlugSegment(candidateSegments[3]) === normalizeLegacySlugSegment(requestedSegments[3]);
181+
}
182+
183+
function decodePathSegments(pathValue: string): string {
184+
return pathValue
185+
.split('/')
186+
.map((segment) => {
187+
try {
188+
return decodeURIComponent(segment);
189+
} catch {
190+
return segment;
191+
}
192+
})
193+
.join('/');
194+
}
195+
158196
/**
159197
* Find the filename for a post matching the given date-based slug path and locale.
160198
* slugPath format: "YYYY/MM/DD/slug-text"
@@ -164,6 +202,21 @@ function generatePostPath(frontmatter: PostFrontmatter): string {
164202
*/
165203
function getPostFilename(slugPath: string, locale: string): string | null {
166204
const files = fs.readdirSync(postsDirectory);
205+
const decodedSlugPath = decodePathSegments(slugPath);
206+
207+
// Legacy NextJS path format: "YYYY-MM-DD-post-slug" (filename base)
208+
if (!decodedSlugPath.includes('/')) {
209+
if (locale === 'de') {
210+
const deFilename = `${decodedSlugPath}.de.md`;
211+
return files.includes(deFilename) ? deFilename : null;
212+
}
213+
214+
const enFilename = `${decodedSlugPath}.md`;
215+
if (files.includes(enFilename) && !enFilename.includes('.de.md')) {
216+
return enFilename;
217+
}
218+
return null;
219+
}
167220

168221
for (const filename of files) {
169222
if (!filename.endsWith('.md')) continue;
@@ -179,7 +232,7 @@ function getPostFilename(slugPath: string, locale: string): string | null {
179232
const fileContents = fs.readFileSync(fullPath, 'utf8');
180233
const { data } = matter(fileContents);
181234

182-
if (generatePostPath(data as PostFrontmatter) === slugPath) {
235+
if (isDateBasedSlugMatch(generatePostPath(data as PostFrontmatter), decodedSlugPath)) {
183236
return filename;
184237
}
185238
}
@@ -580,18 +633,19 @@ export function getAllPostSlugs(): Array<{ locale: string; slug: string[] }> {
580633
const { data } = matter(fileContents);
581634
const frontmatter = data as PostFrontmatter;
582635

583-
// Skip outdated posts from static generation
584-
if (frontmatter.outdated === true) {
585-
continue;
586-
}
587-
588636
const postPath = generatePostPath(frontmatter);
589637
const slugSegments = postPath.split('/');
638+
const legacyBaseSlug = filename.endsWith('.de.md')
639+
? filename.replace('.de.md', '')
640+
: filename.replace('.md', '');
641+
const legacySlugSegments = [legacyBaseSlug];
590642

591643
if (filename.endsWith('.de.md')) {
592644
slugs.push({ locale: 'de', slug: slugSegments });
645+
slugs.push({ locale: 'de', slug: legacySlugSegments });
593646
} else {
594647
slugs.push({ locale: 'en', slug: slugSegments });
648+
slugs.push({ locale: 'en', slug: legacySlugSegments });
595649
}
596650
}
597651

src/middleware.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import createMiddleware from 'next-intl/middleware';
22
import { routing } from './i18n/routing';
33

44
const intlMiddleware = createMiddleware(routing);
5+
const STATIC_FILE_EXTENSION_PATTERN = /\.(?:avif|bmp|css|gif|ico|jpeg|jpg|js|json|map|mp4|png|svg|txt|webm|webp|woff|woff2|xml)$/i;
56

67
export default function middleware(req: any) {
7-
if (req.nextUrl.pathname.endsWith('sitemap.xml')) {
8+
const { pathname } = req.nextUrl;
9+
10+
if (pathname.endsWith('sitemap.xml') || STATIC_FILE_EXTENSION_PATTERN.test(pathname)) {
811
return;
912
}
1013
return intlMiddleware(req);
@@ -14,5 +17,5 @@ export const config = {
1417
// Match all pathnames except for
1518
// - ... if they start with `/api`, `/_next` or `/_vercel`
1619
// - ... if they contain a dot (e.g. `favicon.ico`)
17-
matcher: ['/', '/(de|en)/:path*', '/((?!api|_next|_vercel|.*\\..*).*)'],
20+
matcher: ['/', '/posts/:path*', '/(de|en)/:path*', '/((?!api|_next|_vercel|.*\\..*).*)'],
1821
};

0 commit comments

Comments
 (0)