fix(andreknie.de): normalize SEO metadata and document structure

This commit is contained in:
2026-07-28 21:42:54 +02:00
parent b507199987
commit 215aacf4a4
21 changed files with 161 additions and 33 deletions
@@ -1,33 +1,32 @@
import React from 'react'
import { Helmet } from 'react-helmet-async'
import { absoluteUrl, BRAND_SUFFIX, DEFAULT_DESCRIPTION, DEFAULT_IMAGE, DEFAULT_TITLE, normalizeTitle } from './seo.js'
export default function SEOHead({ title, description, url, image, type = 'website' }) {
const defaultTitle = "Dr. André Knie - Experte für Digitale Souveränität"
const defaultDescription = "Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren."
const siteTitle = title ? `${title} | Dr. André Knie` : defaultTitle
const siteDesc = description || defaultDescription
const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de"
const siteImage = image || "https://andreknie.de/images/og-image.webp"
export default function SEOHead({ title, description, url, image, type = 'website', noindex = false }) {
const pageTitle = normalizeTitle(title)
const siteTitle = pageTitle === DEFAULT_TITLE ? pageTitle : `${pageTitle}${BRAND_SUFFIX}`
const siteDescription = description || DEFAULT_DESCRIPTION
const canonicalUrl = absoluteUrl(url || '/', '/')
const imageUrl = absoluteUrl(image, DEFAULT_IMAGE)
return (
<Helmet>
<title>{siteTitle}</title>
<meta name="description" content={siteDesc} />
{/* Open Graph / Facebook */}
<meta property="og:type" content={type} />
<meta property="og:url" content={siteUrl} />
<meta property="og:title" content={siteTitle} />
<meta property="og:description" content={siteDesc} />
<meta property="og:image" content={siteImage} />
<meta name="description" content={siteDescription} />
<link rel="canonical" href={canonicalUrl} />
{noindex && <meta name="robots" content="noindex,follow" />}
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={siteUrl} />
<meta property="twitter:title" content={siteTitle} />
<meta property="twitter:description" content={siteDesc} />
<meta property="twitter:image" content={siteImage} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={siteTitle} />
<meta property="og:description" content={siteDescription} />
<meta property="og:image" content={imageUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonicalUrl} />
<meta name="twitter:title" content={siteTitle} />
<meta name="twitter:description" content={siteDescription} />
<meta name="twitter:image" content={imageUrl} />
</Helmet>
)
}
@@ -0,0 +1,43 @@
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import SEOHead from './SEOHead'
import { BRAND_SUFFIX } from './seo.js'
afterEach(() => {
cleanup()
document.head.querySelectorAll('[data-rh="true"]').forEach(node => node.remove())
})
describe('SEOHead', () => {
it('renders one branded title, description, and canonical link', () => {
render(
<HelmetProvider>
<SEOHead title={`Artikel${BRAND_SUFFIX}`} description="Synthetic description" url="/artikel" />
</HelmetProvider>,
)
expect(document.head.querySelectorAll('title')).toHaveLength(1)
expect(document.title).toBe(`Artikel${BRAND_SUFFIX}`)
expect(document.title.match(/Dr\. André Knie/g)).toHaveLength(1)
expect(document.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://andreknie.de/artikel')
})
it('normalizes relative and absolute image URLs', () => {
const { rerender } = render(
<HelmetProvider>
<SEOHead title="Test" image="/images/andre-knie.webp" />
</HelmetProvider>,
)
expect(document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe('https://andreknie.de/images/andre-knie.webp')
rerender(
<HelmetProvider>
<SEOHead title="Test" image="https://cdn.example.invalid/image.webp" />
</HelmetProvider>,
)
expect(document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe('https://cdn.example.invalid/image.webp')
})
})
@@ -0,0 +1,16 @@
export const SITE_URL = 'https://andreknie.de'
export const BRAND_SUFFIX = ' | Dr. André Knie'
export const DEFAULT_TITLE = 'Dr. André Knie Digitale Souveränität und KI'
export const DEFAULT_DESCRIPTION = 'Dr. André Knie unterstützt Organisationen dabei, digitale Souveränität zu erlangen und sicher von KI zu profitieren.'
export const DEFAULT_IMAGE = '/images/andre-knie.webp'
export function absoluteUrl(value, fallback) {
const candidate = value || fallback
if (/^https?:\/\//i.test(candidate)) return candidate
return new URL(candidate.startsWith('/') ? candidate : `/${candidate}`, SITE_URL).href
}
export function normalizeTitle(title) {
if (!title) return DEFAULT_TITLE
return title.endsWith(BRAND_SUFFIX) ? title.slice(0, -BRAND_SUFFIX.length) : title
}