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:
@@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<title>Trainingstagebuch – Umfassende Vereinsverwaltung, Trainingsplanung & Turnierorganisation</title>
|
||||
<meta name="description" content="Das TrainingsTagebuch ist die umfassende Lösung für Vereine: Mitgliederverwaltung, Trainingsgruppen, Trainingszeiten, Trainingstagebuch, Turniere (intern, offen, offiziell), Team-Management, MyTischtennis-Integration, Statistiken, Berechtigungssystem – DSGVO‑konform und einfach zu bedienen." />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<link rel="canonical" href="https://tt-tagebuch.de/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
@@ -5,32 +5,8 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/</loc>
|
||||
<lastmod>2025-01-11</lastmod>
|
||||
<lastmod>2026-03-18</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/register</loc>
|
||||
<lastmod>2025-01-11</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/login</loc>
|
||||
<lastmod>2025-01-11</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/impressum</loc>
|
||||
<lastmod>2025-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/datenschutz</loc>
|
||||
<lastmod>2025-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -26,35 +26,36 @@ import MemberTransferSettingsView from './views/MemberTransferSettingsView.vue';
|
||||
import PersonalSettings from './views/PersonalSettings.vue';
|
||||
import Impressum from './views/Impressum.vue';
|
||||
import Datenschutz from './views/Datenschutz.vue';
|
||||
import { applySeoForPath } from './utils/seo.js';
|
||||
|
||||
const routes = [
|
||||
{ path: '/register', component: Register },
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/activate/:activationCode', component: Activate },
|
||||
{ path: '/forgot-password', component: ForgotPassword },
|
||||
{ path: '/reset-password/:token', component: ResetPassword },
|
||||
{ path: '/', component: Home },
|
||||
{ path: '/createclub', component: CreateClub },
|
||||
{ path: '/showclub/:clubId', component: ClubView },
|
||||
{ path: '/members', component: MembersView },
|
||||
{ path: '/diary', component: DiaryView },
|
||||
{ path: '/pending-approvals', component: PendingApprovalsView},
|
||||
{ path: '/schedule', component: ScheduleView},
|
||||
{ path: '/tournaments', component: TournamentsView },
|
||||
{ path: '/tournament-participations', component: OfficialTournaments },
|
||||
{ path: '/training-stats', component: TrainingStatsView },
|
||||
{ path: '/club-settings', component: ClubSettings },
|
||||
{ path: '/predefined-activities', component: PredefinedActivities },
|
||||
{ path: '/mytischtennis-account', component: MyTischtennisAccount },
|
||||
{ path: '/clicktt-account', component: ClickTtAccount },
|
||||
{ path: '/team-management', component: TeamManagementView },
|
||||
{ path: '/permissions', component: PermissionsView },
|
||||
{ path: '/logs', component: LogsView },
|
||||
{ path: '/clicktt', component: ClickTtView },
|
||||
{ path: '/member-transfer-settings', component: MemberTransferSettingsView },
|
||||
{ path: '/personal-settings', component: PersonalSettings },
|
||||
{ path: '/impressum', component: Impressum },
|
||||
{ path: '/datenschutz', component: Datenschutz },
|
||||
{ path: '/register', name: 'register', component: Register },
|
||||
{ path: '/login', name: 'login', component: Login },
|
||||
{ path: '/activate/:activationCode', name: 'activate', component: Activate },
|
||||
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword },
|
||||
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword },
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/createclub', name: 'create-club', component: CreateClub },
|
||||
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView },
|
||||
{ path: '/members', name: 'members', component: MembersView },
|
||||
{ path: '/diary', name: 'diary', component: DiaryView },
|
||||
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
|
||||
{ path: '/schedule', name: 'schedule', component: ScheduleView},
|
||||
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
|
||||
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
|
||||
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },
|
||||
{ path: '/club-settings', name: 'club-settings', component: ClubSettings },
|
||||
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities },
|
||||
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount },
|
||||
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount },
|
||||
{ path: '/team-management', name: 'team-management', component: TeamManagementView },
|
||||
{ path: '/permissions', name: 'permissions', component: PermissionsView },
|
||||
{ path: '/logs', name: 'logs', component: LogsView },
|
||||
{ path: '/clicktt', name: 'clicktt', component: ClickTtView },
|
||||
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView },
|
||||
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings },
|
||||
{ path: '/impressum', name: 'impressum', component: Impressum },
|
||||
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -62,4 +63,8 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
applySeoForPath(to.path);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
127
frontend/src/utils/seo.js
Normal file
127
frontend/src/utils/seo.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const SITE_NAME = 'Trainingstagebuch';
|
||||
const SITE_URL = 'https://tt-tagebuch.de';
|
||||
const DEFAULT_IMAGE = `${SITE_URL}/android-chrome-512x512.png`;
|
||||
|
||||
const DEFAULT_SEO = {
|
||||
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 ROUTE_SEO = {
|
||||
'/': {
|
||||
title: DEFAULT_SEO.title,
|
||||
description: DEFAULT_SEO.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 normalizePath(path = '/') {
|
||||
if (!path || path === '') return '/';
|
||||
if (path === '/') return '/';
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
export function getSeoConfigForPath(path) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const matchedPrefix = Object.keys(ROUTE_SEO)
|
||||
.filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
const routeSeo = (matchedPrefix && ROUTE_SEO[matchedPrefix]) || ROUTE_SEO[normalizedPath] || DEFAULT_SEO;
|
||||
const canonicalPath = normalizedPath === '/' ? '' : normalizedPath;
|
||||
|
||||
return {
|
||||
title: routeSeo.title || DEFAULT_SEO.title,
|
||||
description: routeSeo.description || DEFAULT_SEO.description,
|
||||
robots: routeSeo.robots || DEFAULT_SEO.robots,
|
||||
canonical: `${SITE_URL}${canonicalPath}`,
|
||||
url: `${SITE_URL}${canonicalPath}`,
|
||||
image: DEFAULT_IMAGE
|
||||
};
|
||||
}
|
||||
|
||||
function upsertMeta(selector, attributes) {
|
||||
let element = document.head.querySelector(selector);
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
function upsertLink(selector, attributes) {
|
||||
let element = document.head.querySelector(selector);
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('link');
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function applySeoForPath(path) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const seo = getSeoConfigForPath(path);
|
||||
|
||||
document.title = seo.title;
|
||||
|
||||
upsertMeta('meta[name="description"]', { name: 'description', content: seo.description });
|
||||
upsertMeta('meta[name="robots"]', { name: 'robots', content: seo.robots });
|
||||
upsertMeta('meta[property="og:type"]', { property: 'og:type', content: 'website' });
|
||||
upsertMeta('meta[property="og:site_name"]', { property: 'og:site_name', content: SITE_NAME });
|
||||
upsertMeta('meta[property="og:title"]', { property: 'og:title', content: seo.title });
|
||||
upsertMeta('meta[property="og:description"]', { property: 'og:description', content: seo.description });
|
||||
upsertMeta('meta[property="og:url"]', { property: 'og:url', content: seo.url });
|
||||
upsertMeta('meta[property="og:image"]', { property: 'og:image', content: seo.image });
|
||||
upsertMeta('meta[name="twitter:card"]', { name: 'twitter:card', content: 'summary_large_image' });
|
||||
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title', content: seo.title });
|
||||
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description', content: seo.description });
|
||||
upsertMeta('meta[name="twitter:image"]', { name: 'twitter:image', content: seo.image });
|
||||
upsertLink('link[rel="canonical"]', { rel: 'canonical', href: seo.canonical });
|
||||
}
|
||||
Reference in New Issue
Block a user