All checks were successful
Deploy to production / deploy (push) Successful in 2m0s
- Introduced new routes for Bisaya learning and German for Bisaya courses, enhancing the marketing section of the application. - Updated sitemap.xml to include new course URLs for better SEO visibility. - Added localization entries for both courses in English and German, improving content accessibility for users. - Enhanced SEO metadata generation for the new courses, ensuring proper indexing and visibility in search engines. - Updated VocabLandingView to feature links to the new courses, improving user navigation and engagement.
423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
import { getPublicBaseUrl } from './appConfig.js';
|
|
import {
|
|
SEO_UI_LOCALES,
|
|
HREFLANG_FOR_UI,
|
|
OG_LOCALE_FOR_UI,
|
|
} from '../i18n/supportedLocales.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`;
|
|
|
|
export { SEO_UI_LOCALES, HREFLANG_FOR_UI, OG_LOCALE_FOR_UI };
|
|
|
|
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|fr). */
|
|
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 === 'BisayaLearningLanding') {
|
|
return [
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Course',
|
|
name: t('seo.bisayaLearning.jsonLdName'),
|
|
url: buildAbsoluteUrl('/bisaya-lernen'),
|
|
description: t('seo.bisayaLearning.jsonLdDescription'),
|
|
inLanguage: lang,
|
|
provider: {
|
|
'@type': 'Organization',
|
|
name: DEFAULT_SITE_NAME,
|
|
sameAs: buildAbsoluteUrl('/'),
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
if (name === 'GermanForBisayaLanding') {
|
|
return [
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Course',
|
|
name: t('seo.germanForBisaya.jsonLdName'),
|
|
url: buildAbsoluteUrl('/deutsch-fuer-bisaya'),
|
|
description: t('seo.germanForBisaya.jsonLdDescription'),
|
|
inLanguage: lang,
|
|
provider: {
|
|
'@type': 'Organization',
|
|
name: DEFAULT_SITE_NAME,
|
|
sameAs: buildAbsoluteUrl('/'),
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
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();
|
|
}
|