feat(SEO): implement SEO configuration and meta tag management

- Added SEO defaults and route-specific configurations for improved search engine visibility.
- Introduced functions to normalize paths and retrieve SEO settings based on the current route.
- Enhanced server-side rendering to dynamically update meta tags for title, description, and robots directives.
- Created a utility for managing SEO-related meta and link tags in the frontend, ensuring consistent application of SEO practices across routes.
- Updated sitemap with new last modification dates and removed outdated entries for better search engine indexing.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 18:15:01 +01:00
parent 0bb636b91d
commit f94914703a
5 changed files with 280 additions and 56 deletions

View File

@@ -69,6 +69,80 @@ function captureRawBody(req, res, buf, encoding) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SEO_DEFAULTS = {
title: 'Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere',
description: 'Trainingstagebuch ist die Software für Tischtennisvereine: Mitgliederverwaltung, Trainingsplanung, Trainingstagebuch, Gruppen, Turniere, Team-Management, Statistiken und MyTischtennis-Integration.',
robots: 'index,follow',
};
const SEO_ROUTE_CONFIG = {
'/': {
title: SEO_DEFAULTS.title,
description: SEO_DEFAULTS.description,
robots: 'index,follow',
},
'/login': {
title: 'Login | Trainingstagebuch',
description: 'Im Trainingstagebuch einloggen und Vereinsdaten, Trainingsplanung, Mitglieder und Turniere verwalten.',
robots: 'noindex,follow',
},
'/register': {
title: 'Registrieren | Trainingstagebuch',
description: 'Kostenlos im Trainingstagebuch registrieren und die Vereinsverwaltung für Tischtennisvereine kennenlernen.',
robots: 'noindex,follow',
},
'/activate': {
title: 'Konto aktivieren | Trainingstagebuch',
description: 'Aktivierung des Benutzerkontos im Trainingstagebuch.',
robots: 'noindex,follow',
},
'/forgot-password': {
title: 'Passwort vergessen | Trainingstagebuch',
description: 'Zugang zum Trainingstagebuch wiederherstellen.',
robots: 'noindex,follow',
},
'/reset-password': {
title: 'Passwort zurücksetzen | Trainingstagebuch',
description: 'Passwort im Trainingstagebuch sicher zurücksetzen.',
robots: 'noindex,follow',
},
'/impressum': {
title: 'Impressum | Trainingstagebuch',
description: 'Impressum von Trainingstagebuch.',
robots: 'noindex,follow',
},
'/datenschutz': {
title: 'Datenschutzerklärung | Trainingstagebuch',
description: 'Datenschutzerklärung von Trainingstagebuch.',
robots: 'noindex,follow',
},
};
function normalizeSeoPath(pathname = '/') {
if (!pathname || pathname === '') return '/';
if (pathname === '/') return '/';
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
function getSeoConfigForPath(pathname = '/') {
const normalizedPath = normalizeSeoPath(pathname);
const matchedPrefix = Object.keys(SEO_ROUTE_CONFIG)
.filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath))
.sort((a, b) => b.length - a.length)[0];
return (matchedPrefix && SEO_ROUTE_CONFIG[matchedPrefix])
|| SEO_ROUTE_CONFIG[normalizedPath]
|| { ...SEO_DEFAULTS, robots: 'noindex,follow' };
}
function escapeHtmlAttribute(value = '') {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// CORS-Konfiguration - Socket.IO hat seine eigene CORS-Konfiguration
app.use(cors({
origin: true,
@@ -166,12 +240,53 @@ const setCanonicalTag = (req, res, next) => {
const protocol = req.protocol || 'https';
const host = req.get('host') || 'tt-tagebuch.de';
const canonicalHost = host.replace(/^www\./, ''); // Entferne www falls vorhanden
const canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`;
const normalizedPath = normalizeSeoPath(req.path);
const canonicalUrl = `${protocol}://${canonicalHost}${normalizedPath === '/' ? '' : normalizedPath}`;
const seo = getSeoConfigForPath(normalizedPath);
// Ersetze den kanonischen Tag
const updatedData = data.replace(
let updatedData = data.replace(
/<title>[^<]*<\/title>/,
`<title>${escapeHtmlAttribute(seo.title)}</title>`
);
updatedData = updatedData.replace(
/<meta name="description" content="[^"]*" \/>/,
`<meta name="description" content="${escapeHtmlAttribute(seo.description)}" />`
);
updatedData = updatedData.replace(
/<meta name="robots" content="[^"]*" \/>/,
`<meta name="robots" content="${escapeHtmlAttribute(seo.robots)}" />`
);
updatedData = updatedData.replace(
/<link rel="canonical" href="[^"]*" \/>/,
`<link rel="canonical" href="${canonicalUrl}" />`
`<link rel="canonical" href="${escapeHtmlAttribute(canonicalUrl)}" />`
);
updatedData = updatedData.replace(
/<meta property="og:title" content="[^"]*" \/>/,
`<meta property="og:title" content="${escapeHtmlAttribute(seo.title)}" />`
);
updatedData = updatedData.replace(
/<meta property="og:description" content="[^"]*" \/>/,
`<meta property="og:description" content="${escapeHtmlAttribute(seo.description)}" />`
);
updatedData = updatedData.replace(
/<meta property="og:url" content="[^"]*" \/>/,
`<meta property="og:url" content="${escapeHtmlAttribute(canonicalUrl)}" />`
);
updatedData = updatedData.replace(
/<meta name="twitter:title" content="[^"]*" \/>/,
`<meta name="twitter:title" content="${escapeHtmlAttribute(seo.title)}" />`
);
updatedData = updatedData.replace(
/<meta name="twitter:description" content="[^"]*" \/>/,
`<meta name="twitter:description" content="${escapeHtmlAttribute(seo.description)}" />`
);
res.setHeader('Content-Type', 'text/html');