fix(andreknie.de): normalize SEO metadata and document structure
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user