feat(seo): enhance multilingual support and SEO handling
All checks were successful
Deploy to production / deploy (push) Successful in 2m46s
All checks were successful
Deploy to production / deploy (push) Successful in 2m46s
- 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.
This commit is contained in:
@@ -5,6 +5,22 @@ 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'],
|
||||
@@ -22,6 +38,13 @@ const MANAGED_META_KEYS = [
|
||||
['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;
|
||||
}
|
||||
@@ -50,6 +73,14 @@ 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;
|
||||
@@ -59,6 +90,159 @@ export function buildAbsoluteUrl(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, ' ')
|
||||
@@ -114,6 +298,13 @@ export function applySeo(overrides = {}) {
|
||||
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');
|
||||
@@ -127,23 +318,64 @@ export function applySeo(overrides = {}) {
|
||||
export function applyRouteSeo(route) {
|
||||
const seo = route.meta?.seo || {};
|
||||
const isProtected = !!route.meta?.requiresAuth;
|
||||
|
||||
const title = seo.title || DEFAULT_TITLE;
|
||||
const description = seo.description || DEFAULT_DESCRIPTION;
|
||||
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: seo.keywords,
|
||||
keywords,
|
||||
robots,
|
||||
type: seo.type,
|
||||
image: seo.image,
|
||||
locale: seo.locale,
|
||||
lang: seo.lang,
|
||||
jsonLd: isProtected ? [] : (seo.jsonLd || []),
|
||||
locale: uiLocaleToOgLocale(uiLocale),
|
||||
lang: uiLocaleToHtmlLang(uiLocale),
|
||||
jsonLd,
|
||||
includeHreflangAlternates: !isProtected,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,4 +389,6 @@ export function resetSeo() {
|
||||
|
||||
document.head.querySelector('link[rel="canonical"]')?.remove();
|
||||
clearManagedJsonLd();
|
||||
clearManagedHreflang();
|
||||
clearManagedOgLocaleAlternates();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user