Files
yourpart3/frontend/src/utils/seo.js
Torsten Schulz (local) c5b8860605
All checks were successful
Deploy to production / deploy (push) Successful in 2m46s
feat(seo): enhance multilingual support and SEO handling
- Added support for multiple languages in the frontend, including English, Spanish, and Cebuano, improving accessibility for a broader audience.
- Implemented hreflang links for better SEO performance, ensuring search engines can correctly index language-specific content.
- Updated SEO metadata handling to utilize internationalization keys, enhancing the clarity and relevance of page titles and descriptions.
- Refactored SEO utility functions to streamline the management of OpenGraph and hreflang attributes, improving maintainability and performance.
2026-04-07 15:43:16 +02:00

395 lines
12 KiB
JavaScript

import { getPublicBaseUrl } from './appConfig.js';
const DEFAULT_BASE_URL = 'https://www.your-part.de';
const DEFAULT_SITE_NAME = 'YourPart';
const DEFAULT_TITLE = 'YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele';
const DEFAULT_DESCRIPTION = 'YourPart verbindet Community, Chat, Forum, Blogs, Vokabeltrainer, das Aufbauspiel Falukant und Browser-Minispiele auf einer Plattform.';
const DEFAULT_IMAGE = `${DEFAULT_BASE_URL}/images/logos/logo.png`;
const SEO_UI_LOCALES = ['de', 'en', 'es', 'ceb'];
const HREFLANG_FOR_UI = {
de: 'de',
en: 'en',
es: 'es',
ceb: 'ceb',
};
const OG_LOCALE_FOR_UI = {
de: 'de_DE',
en: 'en_GB',
es: 'es_ES',
ceb: 'ceb_PH',
};
const MANAGED_META_KEYS = [
['name', 'description'],
['name', 'keywords'],
['name', 'robots'],
['name', 'twitter:card'],
['name', 'twitter:title'],
['name', 'twitter:description'],
['name', 'twitter:image'],
['property', 'og:type'],
['property', 'og:site_name'],
['property', 'og:title'],
['property', 'og:description'],
['property', 'og:url'],
['property', 'og:locale'],
['property', 'og:image'],
];
/** @type {null | (() => { t: Function, te: Function, locale: string })} */
let getSeoI18n = null;
export function setSeoI18nAccessor(fn) {
getSeoI18n = typeof fn === 'function' ? fn : null;
}
function getBaseUrl() {
return getPublicBaseUrl().replace(/\/$/, '') || DEFAULT_BASE_URL;
}
function upsertMeta(attr, key, content) {
let element = document.head.querySelector(`meta[${attr}="${key}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attr, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
}
function upsertLink(rel, href) {
let element = document.head.querySelector(`link[rel="${rel}"]`);
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', rel);
document.head.appendChild(element);
}
element.setAttribute('href', href);
}
function clearManagedJsonLd() {
document.head.querySelectorAll('script[data-seo-managed="true"]').forEach((node) => node.remove());
}
function clearManagedHreflang() {
document.head.querySelectorAll('link[data-seo-managed="hreflang"]').forEach((node) => node.remove());
}
function clearManagedOgLocaleAlternates() {
document.head.querySelectorAll('meta[data-seo-managed="og-locale-alt"]').forEach((node) => node.remove());
}
export function buildAbsoluteUrl(path = '/') {
if (/^https?:\/\//i.test(path)) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${getBaseUrl()}${normalizedPath}`;
}
/**
* URL für hreflang: Deutsch ohne ?lang=, andere Sprachen mit ?lang=.
*/
export function buildHreflangUrl(canonicalPath = '/', uiLocale = 'de') {
const base = buildAbsoluteUrl(canonicalPath).split('?')[0];
if (uiLocale === 'de') {
return base;
}
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}lang=${encodeURIComponent(uiLocale)}`;
}
function appendHreflangAlternate(canonicalPath) {
SEO_UI_LOCALES.forEach((ui) => {
const link = document.createElement('link');
link.setAttribute('rel', 'alternate');
link.setAttribute('hreflang', HREFLANG_FOR_UI[ui] || ui);
link.setAttribute('href', buildHreflangUrl(canonicalPath, ui));
link.dataset.seoManaged = 'hreflang';
document.head.appendChild(link);
});
const xDefault = document.createElement('link');
xDefault.setAttribute('rel', 'alternate');
xDefault.setAttribute('hreflang', 'x-default');
xDefault.setAttribute('href', buildHreflangUrl(canonicalPath, 'de'));
xDefault.dataset.seoManaged = 'hreflang';
document.head.appendChild(xDefault);
}
function appendOgLocaleAlternates(currentOgLocale) {
const used = new Set([currentOgLocale]);
Object.values(OG_LOCALE_FOR_UI).forEach((og) => {
if (used.has(og)) {
return;
}
used.add(og);
const meta = document.createElement('meta');
meta.setAttribute('property', 'og:locale:alternate');
meta.setAttribute('content', og);
meta.dataset.seoManaged = 'og-locale-alt';
document.head.appendChild(meta);
});
}
function uiLocaleToOgLocale(ui) {
return OG_LOCALE_FOR_UI[ui] || OG_LOCALE_FOR_UI.de;
}
/** OpenGraph locale (z. B. de_DE) aus UI-Sprache (de|en|es|ceb). */
export function seoOgLocale(ui) {
return uiLocaleToOgLocale(ui);
}
export function seoHtmlLang(ui) {
return uiLocaleToHtmlLang(ui);
}
function uiLocaleToHtmlLang(ui) {
return HREFLANG_FOR_UI[ui] || 'de';
}
export function seoSchemaLang(ui) {
return HREFLANG_FOR_UI[ui] || 'de';
}
/**
* JSON-LD für öffentliche Marketing-/Start-Routen (sprachabhängig).
*/
export function buildRouteJsonLd(route, t, uiLocale) {
const lang = seoSchemaLang(uiLocale);
const name = route.name;
if (name === 'Home') {
return [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: DEFAULT_SITE_NAME,
url: buildAbsoluteUrl('/'),
inLanguage: lang,
description: t('seo.home.jsonLdDescription'),
potentialAction: {
'@type': 'SearchAction',
target: `${buildAbsoluteUrl('/blogs')}?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
},
];
}
if (name === 'FalukantLanding') {
return [
{
'@context': 'https://schema.org',
'@type': 'VideoGame',
name: t('seo.falukant.jsonLdName'),
url: buildAbsoluteUrl('/falukant'),
description: t('seo.falukant.jsonLdDescription'),
gamePlatform: 'Web Browser',
applicationCategory: 'Game',
inLanguage: lang,
publisher: {
'@type': 'Organization',
name: DEFAULT_SITE_NAME,
},
},
];
}
if (name === 'MinigamesLanding') {
return [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('seo.minigames.jsonLdCollectionName'),
url: buildAbsoluteUrl('/minigames'),
description: t('seo.minigames.jsonLdDescription'),
inLanguage: lang,
},
];
}
if (name === 'VocabLanding') {
return [
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: t('seo.vocab.jsonLdName'),
url: buildAbsoluteUrl('/vokabeltrainer'),
description: t('seo.vocab.jsonLdDescription'),
applicationCategory: 'EducationalApplication',
operatingSystem: 'Web',
inLanguage: lang,
},
];
}
if (name === 'BlogList') {
return [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('seo.blogList.jsonLdName'),
url: buildAbsoluteUrl('/blogs'),
description: t('seo.blogList.jsonLdDescription'),
inLanguage: lang,
},
];
}
return [];
}
export function stripHtml(html = '') {
return html
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function truncateText(text = '', maxLength = 160) {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
export function createBlogSlug(ownerUsername = '', blogTitle = '') {
const usernamePart = String(ownerUsername || '').trim();
const titlePart = String(blogTitle || '')
.replace(/\s+/g, '')
.trim();
return `${usernamePart}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, '');
}
export function applySeo(overrides = {}) {
const title = overrides.title || DEFAULT_TITLE;
const description = overrides.description || DEFAULT_DESCRIPTION;
const keywords = overrides.keywords || 'YourPart, Community, Chat, Forum, Blog, Vokabeltrainer, Falukant, Minispiele';
const robots = overrides.robots || 'index, follow';
const canonical = buildAbsoluteUrl(overrides.canonicalPath || '/');
const image = buildAbsoluteUrl(overrides.image || DEFAULT_IMAGE);
const type = overrides.type || 'website';
const locale = overrides.locale || 'de_DE';
document.title = title;
document.documentElement.setAttribute('lang', overrides.lang || 'de');
upsertMeta('name', 'description', description);
upsertMeta('name', 'keywords', keywords);
upsertMeta('name', 'robots', robots);
upsertMeta('name', 'twitter:card', overrides.twitterCard || 'summary_large_image');
upsertMeta('name', 'twitter:title', overrides.twitterTitle || title);
upsertMeta('name', 'twitter:description', overrides.twitterDescription || description);
upsertMeta('name', 'twitter:image', image);
upsertMeta('property', 'og:type', type);
upsertMeta('property', 'og:site_name', DEFAULT_SITE_NAME);
upsertMeta('property', 'og:title', overrides.ogTitle || title);
upsertMeta('property', 'og:description', overrides.ogDescription || description);
upsertMeta('property', 'og:url', canonical);
upsertMeta('property', 'og:locale', locale);
upsertMeta('property', 'og:image', image);
upsertLink('canonical', canonical);
clearManagedHreflang();
clearManagedOgLocaleAlternates();
if (overrides.includeHreflangAlternates) {
appendHreflangAlternate(overrides.canonicalPath || '/');
appendOgLocaleAlternates(locale);
}
clearManagedJsonLd();
for (const payload of overrides.jsonLd || []) {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.dataset.seoManaged = 'true';
script.textContent = JSON.stringify(payload);
document.head.appendChild(script);
}
}
export function applyRouteSeo(route) {
const seo = route.meta?.seo || {};
const isProtected = !!route.meta?.requiresAuth;
const canonicalPath = seo.canonicalPath || route.path || '/';
const robots = seo.robots || route.meta?.robots || (isProtected ? 'noindex, nofollow' : 'index, follow');
const accessor = getSeoI18n ? getSeoI18n() : null;
const i18nKey = seo.i18nKey;
const prefix = i18nKey ? `seo.${i18nKey}` : '';
let title = DEFAULT_TITLE;
let description = DEFAULT_DESCRIPTION;
let keywords;
let jsonLd = [];
if (accessor && prefix && accessor.te(`${prefix}.title`)) {
title = accessor.t(`${prefix}.title`);
description = accessor.te(`${prefix}.description`)
? accessor.t(`${prefix}.description`)
: DEFAULT_DESCRIPTION;
if (accessor.te(`${prefix}.keywords`)) {
keywords = accessor.t(`${prefix}.keywords`);
} else if (accessor.te('seo.default.keywords')) {
keywords = accessor.t('seo.default.keywords');
}
if (!isProtected) {
jsonLd = buildRouteJsonLd(route, accessor.t, accessor.locale);
}
} else {
title = seo.title || DEFAULT_TITLE;
description = seo.description || DEFAULT_DESCRIPTION;
keywords = seo.keywords;
if (!isProtected) {
jsonLd = seo.jsonLd || [];
}
}
if (accessor && accessor.te('seo.default.title') && title === DEFAULT_TITLE && !seo.title) {
title = accessor.t('seo.default.title');
}
if (accessor && accessor.te('seo.default.description') && description === DEFAULT_DESCRIPTION && !seo.description) {
description = accessor.t('seo.default.description');
}
if (!keywords && accessor?.te('seo.default.keywords')) {
keywords = accessor.t('seo.default.keywords');
}
const uiLocale = accessor?.locale && SEO_UI_LOCALES.includes(accessor.locale) ? accessor.locale : 'de';
applySeo({
title,
description,
canonicalPath,
keywords,
robots,
type: seo.type,
image: seo.image,
locale: uiLocaleToOgLocale(uiLocale),
lang: uiLocaleToHtmlLang(uiLocale),
jsonLd,
includeHreflangAlternates: !isProtected,
});
}
export function resetSeo() {
for (const [attr, key] of MANAGED_META_KEYS) {
const element = document.head.querySelector(`meta[${attr}="${key}"]`);
if (element) {
element.remove();
}
}
document.head.querySelector('link[rel="canonical"]')?.remove();
clearManagedJsonLd();
clearManagedHreflang();
clearManagedOgLocaleAlternates();
}