slug i18n

This commit is contained in:
2026-01-20 23:43:01 +01:00
parent f62485a67d
commit abf283c9ab
9 changed files with 267 additions and 42 deletions

View File

@@ -6,6 +6,7 @@ import RelatedProducts from '@/components/RelatedProducts';
import { Badge, Container, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { MDXRemote } from 'next-mdx-remote/rsc';
@@ -38,9 +39,9 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
alternates: {
canonical: `/${locale}/products/${productSlug}`,
languages: {
'de': `/de/products/${productSlug}`,
'en': `/en/products/${productSlug}`,
'x-default': `/en/products/${productSlug}`,
'de': `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
'en': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -65,9 +66,9 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
alternates: {
canonical: `/${locale}/products/${slug.join('/')}`,
languages: {
'de': `/de/products/${slug.join('/')}`,
'en': `/en/products/${slug.join('/')}`,
'x-default': `/en/products/${slug.join('/')}`,
'de': `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
'en': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -133,13 +134,21 @@ export default async function ProductPage({ params }: ProductPageProps) {
const categoryTitle = t(`categories.${categoryKey}.title`);
// Filter products for this category
const filteredProducts = allProducts.filter(p =>
p.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === productSlug ||
const filteredProducts = allProducts.filter(p =>
p.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === productSlug ||
cat === categoryTitle
)
);
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({
...p,
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
}))
);
return (
<div className="flex flex-col min-h-screen bg-white">
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
@@ -161,10 +170,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Section className="bg-neutral-light relative">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product) => (
<Link
key={product.slug}
href={`/${locale}/products/${productSlug}/${product.slug}`}
{productsWithTranslatedSlugs.map((product) => (
<Link
key={product.slug}
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">

View File

@@ -1,11 +1,11 @@
import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Section } from '@/components/ui';
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { mapFileSlugToTranslated } from '@/lib/slugs';
interface ProductsPageProps {
params: {
@@ -39,37 +39,43 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
};
}
export default function ProductsPage({ params }: ProductsPageProps) {
const t = useTranslations('Products');
export default async function ProductsPage({ params }: ProductsPageProps) {
const t = await getTranslations('Products');
// Get translated category slugs
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
const categories = [
{
title: t('categories.lowVoltage.title'),
{
title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${params.locale}/products/low-voltage-cables`
href: `/${params.locale}/products/${lowVoltageSlug}`
},
{
title: t('categories.mediumVoltage.title'),
{
title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${params.locale}/products/medium-voltage-cables`
href: `/${params.locale}/products/${mediumVoltageSlug}`
},
{
title: t('categories.highVoltage.title'),
{
title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${params.locale}/products/high-voltage-cables`
href: `/${params.locale}/products/${highVoltageSlug}`
},
{
title: t('categories.solar.title'),
{
title: t('categories.solar.title'),
desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg',
href: `/${params.locale}/products/solar-cables`
href: `/${params.locale}/products/${solarSlug}`
}
];
@@ -124,7 +130,7 @@ export default function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
<img src={category.icon} alt="" className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
<Image src={category.icon} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
</div>
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">

View File

@@ -1,4 +1,5 @@
import { getAllProducts } from '@/lib/mdx';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import Link from 'next/link';
@@ -35,7 +36,7 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{related.map((product) => {
{related.map(async (product) => {
// Find the category slug for the link
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
const catSlug = categorySlugs.find(slug => {
@@ -46,10 +47,12 @@ export default async function RelatedProducts({ currentSlug, categories, locale
);
}) || 'low-voltage-cables';
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
return (
<Link
key={product.slug}
href={`/${locale}/products/${catSlug}/${product.slug}`}
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs';
export interface PostFrontmatter {
title: string;
@@ -18,8 +19,10 @@ export interface PostMdx {
}
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
// Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale);
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
const filePath = path.join(postsDir, `${slug}.mdx`);
const filePath = path.join(postsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) {
return null;
@@ -29,7 +32,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
const { data, content } = matter(fileContent);
return {
slug,
slug: fileSlug,
frontmatter: data as PostFrontmatter,
content,
};

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs';
export interface ProductFrontmatter {
title: string;
@@ -18,30 +19,32 @@ export interface ProductMdx {
}
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
// Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
// Try exact slug first
let filePath = path.join(productsDir, `${slug}.mdx`);
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) {
// Try with -2 suffix (common in the dumped files)
filePath = path.join(productsDir, `${slug}-2.mdx`);
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
}
if (!fs.existsSync(filePath)) {
// Fallback to English if locale is not 'en'
if (locale !== 'en') {
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
let enFilePath = path.join(enProductsDir, `${slug}.mdx`);
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(enFilePath)) {
enFilePath = path.join(enProductsDir, `${slug}-2.mdx`);
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
}
if (fs.existsSync(enFilePath)) {
const fileContent = fs.readFileSync(enFilePath, 'utf8');
const { data, content } = matter(fileContent);
return {
slug,
slug: fileSlug,
frontmatter: {
...data,
isFallback: true
@@ -57,7 +60,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
const { data, content } = matter(fileContent);
return {
slug,
slug: fileSlug,
frontmatter: data as ProductFrontmatter,
content,
};

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs';
export interface PageFrontmatter {
title: string;
@@ -16,8 +17,10 @@ export interface PageMdx {
}
export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> {
// Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale);
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
const filePath = path.join(pagesDir, `${slug}.mdx`);
const filePath = path.join(pagesDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) {
return null;
@@ -27,7 +30,7 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
const { data, content } = matter(fileContent);
return {
slug,
slug: fileSlug,
frontmatter: data as PageFrontmatter,
content,
};
@@ -41,7 +44,17 @@ export async function getAllPages(locale: string): Promise<PageMdx[]> {
const pages = await Promise.all(
files
.filter(file => file.endsWith('.mdx'))
.map(file => getPageBySlug(file.replace(/\.mdx$/, ''), locale))
.map(file => {
const fileSlug = file.replace(/\.mdx$/, '');
const filePath = path.join(pagesDir, file);
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
const { data, content } = matter(fileContent.content);
return {
slug: fileSlug,
frontmatter: data as PageFrontmatter,
content,
};
})
);
return pages.filter((p): p is PageMdx => p !== null);

96
lib/slugs.ts Normal file
View File

@@ -0,0 +1,96 @@
import { getTranslations } from 'next-intl/server';
/**
* Maps a translated slug to original file slug
* @param translatedSlug - The slug from URL (translated)
* @param _locale - The current locale (unused, kept for API consistency)
* @returns The original file slug, or input slug if no mapping exists
*/
export async function mapSlugToFileSlug(translatedSlug: string, _locale: string): Promise<string> {
const t = await getTranslations('Slugs');
// Check pages
try {
const pageSlug = t.raw(`pages.${translatedSlug}`);
if (pageSlug && typeof pageSlug === 'string') {
return pageSlug;
}
} catch {
// Key doesn't exist, continue
}
// Check products
try {
const productSlug = t.raw(`products.${translatedSlug}`);
if (productSlug && typeof productSlug === 'string') {
return productSlug;
}
} catch {
// Key doesn't exist, continue
}
// Check categories
try {
const categorySlug = t.raw(`categories.${translatedSlug}`);
if (categorySlug && typeof categorySlug === 'string') {
return categorySlug;
}
} catch {
// Key doesn't exist, continue
}
// Return original slug if no mapping found
return translatedSlug;
}
/**
* Maps an original file slug to translated slug for a locale
* @param fileSlug - The original file slug
* @param _locale - The target locale (unused, kept for API consistency)
* @returns The translated slug, or input slug if no mapping exists
*/
export async function mapFileSlugToTranslated(fileSlug: string, _locale: string): Promise<string> {
const t = await getTranslations('Slugs');
// Find the key that maps to this file slug
const sections = ['pages', 'products', 'categories'];
for (const section of sections) {
try {
const sectionData = t.raw(section);
if (sectionData && typeof sectionData === 'object') {
for (const [translatedSlug, mappedSlug] of Object.entries(sectionData)) {
if (mappedSlug === fileSlug) {
return translatedSlug;
}
}
}
} catch {
// Section doesn't exist, continue
}
}
// Return original slug if no mapping found
return fileSlug;
}
/**
* Gets all translated slugs for a section
* @param section - The section name (pages, products, categories)
* @param _locale - The current locale (unused, kept for API consistency)
* @returns Object mapping translated slugs to file slugs
*/
export async function getSlugMappings(section: 'pages' | 'products' | 'categories', _locale: string): Promise<Record<string, string>> {
const t = await getTranslations('Slugs');
try {
const sectionData = t.raw(section);
if (sectionData && typeof (sectionData as any) === 'object') {
return sectionData as Record<string, string>;
}
} catch {
// Section doesn't exist
}
return {};
}

View File

@@ -1,4 +1,50 @@
{
"Slugs": {
"pages": {
"impressum": "legal-notice",
"datenschutz": "privacy-policy",
"agbs": "terms",
"kontakt": "contact",
"team": "team",
"blog": "blog",
"produkte": "products",
"start": "start",
"danke": "thanks"
},
"products": {
"n2x2y": "n2x2y",
"n2xfk2y": "n2xfk2y",
"n2xfkld2y": "n2xfkld2y",
"n2xs2y": "n2xs2y",
"n2xsf2y": "n2xsf2y",
"n2xsfl2y-hv": "n2xsfl2y-hv",
"n2xsfl2y-mv": "n2xsfl2y-mv",
"n2xsy": "n2xsy",
"n2xy": "n2xy",
"na2x2y": "na2x2y",
"na2xfk2y": "na2xfk2y",
"na2xfkld2y": "na2xfkld2y",
"na2xs2y": "na2xs2y",
"na2xsf2y": "na2xsf2y",
"na2xsfl2y-hv": "na2xsfl2y-hv",
"na2xsfl2y-mv": "na2xsfl2y-mv",
"na2xsy": "na2xsy",
"na2xy": "na2xy",
"nay2y": "nay2y",
"naycwy": "naycwy",
"nayy": "nayy",
"ny2y": "ny2y",
"nycwy": "nycwy",
"nyy": "nyy",
"h1z2z2-k": "h1z2z2-k"
},
"categories": {
"niederspannungskabel": "low-voltage-cables",
"mittelspannungskabel": "medium-voltage-cables",
"hochspannungskabel": "high-voltage-cables",
"solarkabel": "solar-cables"
}
},
"Index": {
"title": "KLZ Cables - Hochwertige Kabel",
"description": "Ihr Partner für hochwertige Kabel.",

View File

@@ -1,4 +1,50 @@
{
"Slugs": {
"pages": {
"legal-notice": "legal-notice",
"privacy-policy": "privacy-policy",
"terms": "terms",
"contact": "contact",
"team": "team",
"blog": "blog",
"products": "products",
"start": "start",
"thanks": "thanks"
},
"products": {
"n2x2y": "n2x2y",
"n2xfk2y": "n2xfk2y",
"n2xfkld2y": "n2xfkld2y",
"n2xs2y": "n2xs2y",
"n2xsf2y": "n2xsf2y",
"n2xsfl2y-hv": "n2xsfl2y-hv",
"n2xsfl2y-mv": "n2xsfl2y-mv",
"n2xsy": "n2xsy",
"n2xy": "n2xy",
"na2x2y": "na2x2y",
"na2xfk2y": "na2xfk2y",
"na2xfkld2y": "na2xfkld2y",
"na2xs2y": "na2xs2y",
"na2xsf2y": "na2xsf2y",
"na2xsfl2y-hv": "na2xsfl2y-hv",
"na2xsfl2y-mv": "na2xsfl2y-mv",
"na2xsy": "na2xsy",
"na2xy": "na2xy",
"nay2y": "nay2y",
"naycwy": "naycwy",
"nayy": "nayy",
"ny2y": "ny2y",
"nycwy": "nycwy",
"nyy": "nyy",
"h1z2z2-k": "h1z2z2-k"
},
"categories": {
"low-voltage-cables": "low-voltage-cables",
"medium-voltage-cables": "medium-voltage-cables",
"high-voltage-cables": "high-voltage-cables",
"solar-cables": "solar-cables"
}
},
"Index": {
"title": "KLZ Cables - High Quality Cables",
"description": "Your partner for high quality cables.",