Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 |
@@ -427,39 +427,52 @@ jobs:
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||
- name: 🚀 OG Image Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
- name: 🌐 Full Sitemap HTTP Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm run check:http
|
||||
- name: 🌐 Locale & Language Switcher Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm run check:locale
|
||||
|
||||
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
||||
- name: 🌐 HTML DOM Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:html
|
||||
- name: 🔒 Security Headers Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:security
|
||||
- name: 🔗 Lychee Deep Link Crawl
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:links
|
||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
@@ -556,12 +569,42 @@ jobs:
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
TITLE="klz-cables.com: deploy=$STATUS smoke=$SMOKE"
|
||||
[[ "$STATUS" == "success" && "$SMOKE" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
|
||||
PERF="${{ needs.performance.result }}"
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# Gotify priority scale:
|
||||
# 1-3 = low (silent/info)
|
||||
# 4-5 = normal
|
||||
# 6-7 = high (warning)
|
||||
# 8-10 = critical (alarm)
|
||||
if [[ "$DEPLOY" != "success" ]]; then
|
||||
PRIORITY=10
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="DEPLOY FAILED"
|
||||
elif [[ "$SMOKE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="⚠️"
|
||||
STATUS_LINE="Smoke tests failed"
|
||||
elif [[ "$PERF" != "success" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="📉"
|
||||
STATUS_LINE="Performance degraded"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="All checks passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished.\nDeploy: $STATUS | Smoke: $SMOKE\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
@@ -97,7 +97,7 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/contact`}
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||
eventProperties={{
|
||||
location: 'generic_page_support_cta',
|
||||
|
||||
@@ -24,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/contact`,
|
||||
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/contact`,
|
||||
de: `${SITE_URL}/de/kontakt`,
|
||||
en: `${SITE_URL}/en/contact`,
|
||||
'x-default': `${SITE_URL}/en/contact`,
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/contact`,
|
||||
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
siteName: 'KLZ Cables',
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
|
||||
@@ -55,14 +55,23 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||
const getLocalizedPath = async (lang: string) => {
|
||||
const parts = await Promise.all([
|
||||
mapFileSlugToTranslated('products', lang),
|
||||
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
|
||||
]);
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
if (!product) return {};
|
||||
|
||||
@@ -72,9 +81,9 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
@@ -258,7 +267,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
(node.fields?.blockType === 'productTabs' ||
|
||||
node.fields?.blockType === 'productTechnicalData'),
|
||||
);
|
||||
const descriptionChildren = rootChildren.filter(
|
||||
let descriptionChildren = rootChildren.filter(
|
||||
(node: any) =>
|
||||
!(
|
||||
node.type === 'block' &&
|
||||
@@ -267,6 +276,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
),
|
||||
);
|
||||
|
||||
// If no standalone description nodes, extract from the productTabs block's embedded content
|
||||
if (descriptionChildren.length === 0) {
|
||||
const tabsBlock = rootChildren.find(
|
||||
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (tabsBlock?.fields?.content?.root?.children) {
|
||||
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
|
||||
// Filter out MDX parsing artifacts like `}>`
|
||||
if (node.type === 'paragraph' && node.children?.length === 1) {
|
||||
const text = node.children[0]?.text?.trim();
|
||||
return text !== '}>' && text !== '{' && text !== '}';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const descriptionContent = {
|
||||
root: {
|
||||
...product.content.root,
|
||||
@@ -402,7 +428,13 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* Description Area Next to Sidebar */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
||||
<PayloadRichText data={descriptionContent} />
|
||||
{descriptionChildren.length > 0 ? (
|
||||
<PayloadRichText data={descriptionContent} />
|
||||
) : product.frontmatter.description ? (
|
||||
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ import { getAllPostsMetadata } from '@/lib/blog';
|
||||
import { getAllPagesMetadata } from '@/lib/pages';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.CI
|
||||
? 'http://klz.localhost'
|
||||
: config.baseUrl || 'https://klz-cables.com';
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||
|
||||
@@ -173,12 +173,12 @@ export default function Footer() {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('contact'),
|
||||
href: '/contact',
|
||||
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export default function Header() {
|
||||
const segmentMap: Record<string, Record<string, string>> = {
|
||||
de: {
|
||||
produkte: 'products',
|
||||
kontakt: 'contact',
|
||||
impressum: 'legal-notice',
|
||||
datenschutz: 'privacy-policy',
|
||||
agbs: 'terms',
|
||||
@@ -103,6 +104,7 @@ export default function Header() {
|
||||
},
|
||||
en: {
|
||||
products: 'produkte',
|
||||
contact: 'kontakt',
|
||||
'legal-notice': 'impressum',
|
||||
'privacy-policy': 'datenschutz',
|
||||
terms: 'agbs',
|
||||
@@ -183,24 +185,40 @@ export default function Header() {
|
||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||
</Link>
|
||||
{(() => {
|
||||
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(fullHref);
|
||||
return (
|
||||
<Link
|
||||
href={fullHref}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||
isActive && 'text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
||||
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
@@ -256,7 +274,7 @@ export default function Header() {
|
||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||
@@ -275,7 +293,7 @@ export default function Header() {
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||
textColorClass,
|
||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||
)}
|
||||
@@ -320,7 +338,7 @@ export default function Header() {
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
@@ -344,6 +362,15 @@ export default function Header() {
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
aria-current={
|
||||
(
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||
)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
@@ -352,7 +379,12 @@ export default function Header() {
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||
className={cn(
|
||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||
(item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -388,7 +420,7 @@ export default function Header() {
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
|
||||
@@ -23,10 +23,82 @@ const jsxConverters: JSXConverters = {
|
||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
||||
text: ({ node }: any) => {
|
||||
const text = node.text;
|
||||
// Handle markdown-style lists embedded in text nodes from MDX migration
|
||||
if (text && text.includes('\n- ')) {
|
||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
||||
// If first part doesn't start with "- ", it's a prefix paragraph
|
||||
const startsWithDash = text.trimStart().startsWith('- ');
|
||||
const prefix = startsWithDash ? null : parts.shift();
|
||||
return (
|
||||
<>
|
||||
{prefix && (
|
||||
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
||||
{!prefix.includes('<') ? prefix : undefined}
|
||||
</span>
|
||||
)}
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
{parts.map((item: string, i: number) => {
|
||||
const cleanItem = item.trim();
|
||||
if (cleanItem.includes('<')) {
|
||||
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
|
||||
}
|
||||
return <li key={i}>{cleanItem}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
||||
}
|
||||
|
||||
// Handle markdown-style links [text](url) from MDX migration
|
||||
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const remaining = text;
|
||||
let key = 0;
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
while ((match = linkRegex.exec(remaining)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
||||
>
|
||||
{match[1]}
|
||||
</a>,
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < remaining.length) {
|
||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
||||
}
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
||||
if (text && text.includes('\n')) {
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<>
|
||||
{lines.map((line: string, i: number) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.format === 1) return <strong>{text}</strong>;
|
||||
if (node.format === 2) return <em>{text}</em>;
|
||||
return <span>{text}</span>;
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
"Leitungen",
|
||||
"impressum",
|
||||
"datenschutz",
|
||||
"agbs"
|
||||
"agbs",
|
||||
"kontakt"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
|
||||
47
debug-sitemap.ts
Normal file
47
debug-sitemap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
console.log('DEBUG SCRIPT STARTING...');
|
||||
|
||||
async function debug() {
|
||||
console.log('Importing dependencies...');
|
||||
try {
|
||||
const { getAllProductsMetadata } = await import('./lib/mdx');
|
||||
const { getAllPostsMetadata } = await import('./lib/blog');
|
||||
const { getAllPagesMetadata } = await import('./lib/pages');
|
||||
|
||||
console.log('Dependencies imported.');
|
||||
|
||||
const locales = ['de', 'en'];
|
||||
for (const locale of locales) {
|
||||
console.log(`--- Locale: ${locale} ---`);
|
||||
|
||||
try {
|
||||
const products = await getAllProductsMetadata(locale);
|
||||
console.log(`Products (${locale}): ${products.length}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to get products for ${locale}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
const posts = await getAllPostsMetadata(locale);
|
||||
console.log(`Posts (${locale}): ${posts.length}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to get posts for ${locale}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
const pages = await getAllPagesMetadata(locale);
|
||||
console.log(`Pages (${locale}): ${pages.length}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to get pages for ${locale}:`, e);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Debug failed during setup/imports:', err);
|
||||
}
|
||||
console.log('DEBUG SCRIPT FINISHED.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
debug().catch((err) => {
|
||||
console.error('Unhandled retransmission error in debug():', err);
|
||||
process.exit(1);
|
||||
});
|
||||
11
lib/blog.ts
11
lib/blog.ts
@@ -60,13 +60,15 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
locale: { equals: locale },
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
draft: process.env.NODE_ENV === 'development',
|
||||
draft: isDev,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
@@ -107,19 +109,22 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
// Query only published posts (access checks applied automatically by Payload!)
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
sort: '-date',
|
||||
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
|
||||
draft: isDev,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
console.log(`[Payload] getAllPosts for ${locale}: Found ${docs.length} docs`);
|
||||
|
||||
return docs.map((doc) => {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
|
||||
59
lib/mdx.ts
59
lib/mdx.ts
@@ -186,35 +186,30 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
select: selectFields,
|
||||
});
|
||||
|
||||
let products: ProductMdx[] = result.docs
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean) as string[];
|
||||
console.log(`[Payload] getAllProducts for ${locale}: Found ${result.docs.length} docs`);
|
||||
|
||||
const plainCategories = Array.isArray(doc.categories)
|
||||
? doc.categories.map((c: any) => String(c.category))
|
||||
: [];
|
||||
let products: ProductMdx[] = result.docs.map((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
slug: String(doc.slug),
|
||||
frontmatter: {
|
||||
title: String(doc.title),
|
||||
sku: doc.sku ? String(doc.sku) : '',
|
||||
description: doc.description ? String(doc.description) : '',
|
||||
categories: plainCategories,
|
||||
images: resolvedImages,
|
||||
locale: String(doc.locale),
|
||||
},
|
||||
content: null,
|
||||
};
|
||||
});
|
||||
const plainCategories = Array.isArray(doc.categories)
|
||||
? doc.categories.map((c: any) => String(c.category))
|
||||
: [];
|
||||
|
||||
return {
|
||||
slug: String(doc.slug),
|
||||
frontmatter: {
|
||||
title: String(doc.title),
|
||||
sku: doc.sku ? String(doc.sku) : '',
|
||||
description: doc.description ? String(doc.description) : '',
|
||||
categories: plainCategories,
|
||||
images: resolvedImages,
|
||||
locale: String(doc.locale),
|
||||
},
|
||||
content: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Also include English fallbacks for slugs not in this locale
|
||||
if (locale !== 'en') {
|
||||
@@ -227,14 +222,12 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
select: selectFields,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Payload] getAllProducts (en fallbacks) for ${locale}: Found ${enResult.docs.length} docs`,
|
||||
);
|
||||
|
||||
const fallbacks = enResult.docs
|
||||
.filter((doc) => !localeSlugs.has(doc.slug))
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
|
||||
@@ -415,20 +415,24 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/de/produkte',
|
||||
destination: '/de/products',
|
||||
},
|
||||
{
|
||||
source: '/de/produkte/:path*',
|
||||
destination: '/de/products/:path*',
|
||||
},
|
||||
{
|
||||
source: '/de/kontakt',
|
||||
destination: '/de/contact',
|
||||
},
|
||||
];
|
||||
return {
|
||||
beforeFiles: [
|
||||
{
|
||||
source: '/de/produkte',
|
||||
destination: '/de/products',
|
||||
},
|
||||
{
|
||||
source: '/de/produkte/:path*',
|
||||
destination: '/de/products/:path*',
|
||||
},
|
||||
{
|
||||
source: '/de/kontakt',
|
||||
destination: '/de/contact',
|
||||
},
|
||||
],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||
"check:html": "tsx ./scripts/check-html.ts",
|
||||
"check:http": "tsx ./scripts/check-http.ts",
|
||||
"check:locale": "tsx ./scripts/check-locale.ts",
|
||||
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
||||
"check:security": "tsx ./scripts/check-security.ts",
|
||||
"check:links": "bash ./scripts/check-links.sh",
|
||||
|
||||
191
scripts/check-locale.ts
Normal file
191
scripts/check-locale.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
/**
|
||||
* Locale & Language Switcher Smoke Test
|
||||
*
|
||||
* For every URL in the sitemap:
|
||||
* 1. Fetches the page HTML
|
||||
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
|
||||
* 3. Verifies each alternate URL uses correctly translated slugs
|
||||
* 4. Verifies each alternate URL returns HTTP 200
|
||||
*/
|
||||
|
||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
// Expected slug translations: German key → English value
|
||||
const SLUG_MAP: Record<string, string> = {
|
||||
produkte: 'products',
|
||||
kontakt: 'contact',
|
||||
niederspannungskabel: 'low-voltage-cables',
|
||||
mittelspannungskabel: 'medium-voltage-cables',
|
||||
hochspannungskabel: 'high-voltage-cables',
|
||||
solarkabel: 'solar-cables',
|
||||
impressum: 'legal-notice',
|
||||
datenschutz: 'privacy-policy',
|
||||
agbs: 'terms',
|
||||
};
|
||||
|
||||
// Reverse map: English → German
|
||||
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||
);
|
||||
|
||||
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
|
||||
|
||||
function getExpectedTranslation(
|
||||
sourcePath: string,
|
||||
sourceLocale: string,
|
||||
targetLocale: string,
|
||||
): string {
|
||||
const segments = sourcePath.split('/').filter(Boolean);
|
||||
// First segment is locale
|
||||
segments[0] = targetLocale;
|
||||
|
||||
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||
|
||||
return (
|
||||
'/' +
|
||||
segments
|
||||
.map((seg, i) => {
|
||||
if (i === 0) return seg; // locale
|
||||
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||
})
|
||||
.join('/')
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
|
||||
|
||||
// 1. Fetch sitemap
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
const sitemapRes = await axios.get(sitemapUrl, { headers, validateStatus: (s) => s < 400 });
|
||||
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
|
||||
|
||||
let urls = $sitemap('url loc')
|
||||
.map((_i, el) => $sitemap(el).text())
|
||||
.get();
|
||||
|
||||
const urlPattern = /https?:\/\/[^/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith('http'))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
|
||||
|
||||
let totalChecked = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
const path = new URL(url).pathname;
|
||||
const locale = path.split('/')[1];
|
||||
if (!locale || !['de', 'en'].includes(locale)) continue;
|
||||
|
||||
try {
|
||||
const res = await axios.get(url, { headers, validateStatus: null });
|
||||
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
|
||||
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
// Extract hreflang alternate links
|
||||
const alternates: { hreflang: string; href: string }[] = [];
|
||||
$('link[rel="alternate"][hreflang]').each((_i, el) => {
|
||||
const hreflang = $(el).attr('hreflang') || '';
|
||||
let href = $(el).attr('href') || '';
|
||||
if (href && hreflang && hreflang !== 'x-default') {
|
||||
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ''));
|
||||
alternates.push({ hreflang, href });
|
||||
}
|
||||
});
|
||||
|
||||
if (alternates.length === 0) {
|
||||
// Some pages may not have alternates, that's OK
|
||||
continue;
|
||||
}
|
||||
|
||||
totalChecked++;
|
||||
|
||||
// Validate each alternate
|
||||
let pageOk = true;
|
||||
|
||||
for (const alt of alternates) {
|
||||
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||
|
||||
// 1. Check slug translation is correct
|
||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||
const actualPath = new URL(alt.href).pathname;
|
||||
|
||||
if (actualPath !== expectedPath) {
|
||||
console.error(
|
||||
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
|
||||
);
|
||||
failures.push(
|
||||
`Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
|
||||
);
|
||||
pageOk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Check alternate URL returns 200
|
||||
try {
|
||||
const altRes = await axios.get(alt.href, {
|
||||
headers,
|
||||
validateStatus: null,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
if (altRes.status >= 400) {
|
||||
console.error(`❌ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`);
|
||||
failures.push(`Broken alternate: ${path} → ${alt.href} (${altRes.status})`);
|
||||
pageOk = false;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`);
|
||||
failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`);
|
||||
pageOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageOk) {
|
||||
console.log(
|
||||
`✅ ${path} — alternates OK (${alternates
|
||||
.map((a) => a.hreflang)
|
||||
.filter((h) => h !== locale)
|
||||
.join(', ')})`,
|
||||
);
|
||||
totalPassed++;
|
||||
} else {
|
||||
totalFailed++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
|
||||
totalFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(`📊 Locale Smoke Test Results:`);
|
||||
console.log(` Pages checked: ${totalChecked}`);
|
||||
console.log(` Passed: ${totalPassed}`);
|
||||
console.log(` Failed: ${totalFailed}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n❌ Failures:`);
|
||||
failures.forEach((f) => console.log(` • ${f}`));
|
||||
console.log(`\n❌ Locale Smoke Test FAILED.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`\n✨ All locale alternates are correctly translated and reachable!`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n❌ Critical error:`, err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -165,6 +165,10 @@ do_push() {
|
||||
echo "🖼️ Syncing media files..."
|
||||
rsync -az --delete --progress "$LOCAL_MEDIA_DIR/" "$SSH_HOST:$REMOTE_MEDIA_VOLUME/"
|
||||
|
||||
# Fix ownership: rsync preserves local UID, but container runs as nextjs (1001)
|
||||
echo "🔑 Fixing media file permissions..."
|
||||
ssh "$SSH_HOST" "docker exec -u 0 $REMOTE_APP_CONTAINER chown -R 1001:65533 /app/public/media/ 2>/dev/null || true"
|
||||
|
||||
# 6. Restart app
|
||||
echo "🔄 Restarting $TARGET app container..."
|
||||
ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER"
|
||||
|
||||
Reference in New Issue
Block a user