feat(seo): enhance multilingual support and SEO handling
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:
Torsten Schulz (local)
2026-04-07 15:43:16 +02:00
parent ebb2283646
commit c5b8860605
13 changed files with 542 additions and 98 deletions

View File

@@ -26,6 +26,9 @@
<meta name="theme-color" content="#FF8C5A" />
<link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
<link rel="alternate" hreflang="en" href="%VITE_PUBLIC_BASE_URL%/?lang=en" />
<link rel="alternate" hreflang="es" href="%VITE_PUBLIC_BASE_URL%/?lang=es" />
<link rel="alternate" hreflang="ceb" href="%VITE_PUBLIC_BASE_URL%/?lang=ceb" />
<link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
</head>
@@ -33,11 +36,26 @@
<body>
<div id="app"></div>
<noscript>
<section style="max-width:960px;margin:40px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<section lang="de" style="max-width:960px;margin:40px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<h1>YourPart</h1>
<p>YourPart ist eine Plattform fuer Community, Chat, Forum, Blogs, Vokabeltrainer, das Browser-Aufbauspiel Falukant und Minispiele.</p>
<p>Wichtige Bereiche: <a href="/blogs">Blogs</a>, <a href="/vokabeltrainer">Vokabeltrainer</a>, <a href="/falukant">Falukant</a> und <a href="/minigames">Minispiele</a>.</p>
</section>
<section lang="en" style="max-width:960px;margin:24px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<h2>YourPart (English)</h2>
<p>YourPart is a platform for community, chat, forums, blogs, a vocabulary trainer, the browser builder game Falukant and minigames.</p>
<p>Key areas: <a href="/blogs?lang=en">Blogs</a>, <a href="/vokabeltrainer?lang=en">Vocabulary trainer</a>, <a href="/falukant?lang=en">Falukant</a> and <a href="/minigames?lang=en">Minigames</a>.</p>
</section>
<section lang="es" style="max-width:960px;margin:24px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<h2>YourPart (Español)</h2>
<p>YourPart es una plataforma para comunidad, chat, foros, blogs, entrenador de vocabulario, el juego de construcción Falukant en el navegador y minijuegos.</p>
<p>Áreas: <a href="/blogs?lang=es">Blogs</a>, <a href="/vokabeltrainer?lang=es">Vocabulario</a>, <a href="/falukant?lang=es">Falukant</a> y <a href="/minigames?lang=es">Minijuegos</a>.</p>
</section>
<section lang="ceb" style="max-width:960px;margin:24px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<h2>YourPart (Cebuano)</h2>
<p>Ang YourPart usa ka plataporma alang sa komunidad, chat, forum, blog, trainer sa bokabularyo, ang browser game nga Falukant ug minigames.</p>
<p>Mga bahin: <a href="/blogs?lang=ceb">Blogs</a>, <a href="/vokabeltrainer?lang=ceb">Bokabularyo</a>, <a href="/falukant?lang=ceb">Falukant</a>, <a href="/minigames?lang=ceb">Minigames</a>.</p>
</section>
</noscript>
<script type="module" src="/src/main.js"></script>
</body>

View File

@@ -19,6 +19,7 @@ import enBlog from './locales/en/blog.json';
import enMinigames from './locales/en/minigames.json';
import enMessage from './locales/en/message.json';
import enPersonal from './locales/en/personal.json';
import enSeo from './locales/en/seo.json';
import cebGeneral from './locales/ceb/general.json';
import cebHeader from './locales/ceb/header.json';
import cebNavigation from './locales/ceb/navigation.json';
@@ -37,6 +38,7 @@ import cebPersonal from './locales/ceb/personal.json';
import cebFalukant from './locales/ceb/falukant.json';
import cebBlog from './locales/ceb/blog.json';
import cebMinigames from './locales/ceb/minigames.json';
import cebSeo from './locales/ceb/seo.json';
import deGeneral from './locales/de/general.json';
import deHeader from './locales/de/header.json';
@@ -56,6 +58,7 @@ import deBlog from './locales/de/blog.json';
import deMinigames from './locales/de/minigames.json';
import deMessage from './locales/de/message.json';
import dePersonal from './locales/de/personal.json';
import deSeo from './locales/de/seo.json';
import esGeneral from './locales/es/general.json';
import esHeader from './locales/es/header.json';
@@ -75,6 +78,7 @@ import esBlog from './locales/es/blog.json';
import esMinigames from './locales/es/minigames.json';
import esMessage from './locales/es/message.json';
import esPersonal from './locales/es/personal.json';
import esSeo from './locales/es/seo.json';
const messages = {
en: {
@@ -96,6 +100,7 @@ const messages = {
...enMinigames,
...enMessage,
...enPersonal,
...enSeo,
},
ceb: {
...enGeneral,
@@ -134,6 +139,7 @@ const messages = {
...cebFalukant,
...cebBlog,
...cebMinigames,
...cebSeo,
},
de: {
'Ok': 'Ok',
@@ -155,6 +161,7 @@ const messages = {
...deMinigames,
...deMessage,
...dePersonal,
...deSeo,
},
es: {
...esGeneral,
@@ -175,6 +182,7 @@ const messages = {
...esMinigames,
...esMessage,
...esPersonal,
...esSeo,
}
};

View File

@@ -0,0 +1,51 @@
{
"seo": {
"default": {
"title": "YourPart - Komunidad, chat, forum, bokabularyo, Falukant ug minigames",
"description": "YourPart naghiusa sa komunidad, chat, forum, blog, trainer sa bokabularyo, ang browser builder game nga Falukant ug minigames sa usa ka plataporma.",
"keywords": "YourPart, komunidad, chat, forum, blog, bokabularyo, Falukant, minigames, libre, pribado, privacy"
},
"home": {
"title": "YourPart - Komunidad, chat, forum, blogs, bokabularyo ug mga dula",
"description": "YourPart usa ka plataporma sa komunidad nga adunay chat, forum, blogs, trainer sa bokabularyo, ang browser game nga Falukant ug minigames.",
"keywords": "YourPart, komunidad, chat, forum, blogs, bokabularyo, browser game, Falukant, minigames, libre, pribado",
"jsonLdDescription": "Plataporma sa komunidad nga adunay chat, forum, blogs, bokabularyo, Falukant ug minigames sa browser."
},
"falukant": {
"title": "Falukant - Medieval nga browser builder game sa YourPart",
"description": "Falukant ang medieval nga browser builder game sa YourPart nga adunay komersyo, politika, pamilya, edukasyon ug pag-uswag sa karakter.",
"keywords": "Falukant, browser game, builder, medieval, ekonomiya, politika, YourPart, libre",
"jsonLdDescription": "Medieval nga browser builder game nga adunay komersyo, politika, pamilya ug pag-uswag sa karakter.",
"jsonLdName": "Falukant"
},
"minigames": {
"title": "Minigames sa YourPart - Match 3 ug taxi sa browser",
"description": "Diskubreha ang minigames sa browser sa YourPart: Match 3 ug taxi naghatag og paspas nga mga round sa plataporma.",
"keywords": "minigames, browser games, Match 3, taxi, casual, YourPart, libre",
"jsonLdDescription": "Minigames sa browser sa YourPart nga adunay Match 3 ug taxi.",
"jsonLdCollectionName": "Minigames sa YourPart"
},
"vocab": {
"title": "Trainer sa bokabularyo sa YourPart - pagkat-on og pinulongan online",
"description": "Ang trainer sa bokabularyo sa YourPart motabang kanimo sa pagkat-on og pinulongan nga adunay interaktibong leksyon, kurso ug ehersisyo.",
"keywords": "bokabularyo, pagkat-on pinulongan, online, kurso, ehersisyo, YourPart, libre",
"jsonLdDescription": "Interaktibong trainer sa bokabularyo nga adunay kurso, leksyon ug ehersisyo.",
"jsonLdName": "Bokabularyo YourPart"
},
"blogList": {
"title": "Blogs sa YourPart - mga post ug hilisgutan sa komunidad",
"description": "Tan-awa ang publiko nga blogs sa YourPart nga adunay mga post, hunahuna, kasinatian ug hilisgutan.",
"keywords": "blogs, blog sa komunidad, artikulo, post, YourPart",
"jsonLdDescription": "Publiko nga blogs ug mga post sa komunidad sa YourPart.",
"jsonLdName": "Blogs sa YourPart"
},
"blogPage": {
"title": "Blogs sa YourPart",
"description": "Publiko nga blogs, post ug sulod sa komunidad sa YourPart.",
"keywords": "blog, YourPart, komunidad"
},
"blogPost": {
"pageTitle": "{title} | Blog sa YourPart"
}
}
}

View File

@@ -0,0 +1,51 @@
{
"seo": {
"default": {
"title": "YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele",
"description": "YourPart verbindet Community, Chat, Forum, Blogs, Vokabeltrainer, das Aufbauspiel Falukant und Browser-Minispiele auf einer Plattform.",
"keywords": "YourPart, Community, Chat, Forum, Blog, Vokabeltrainer, Falukant, Minispiele, kostenlos, privat, Datenschutz"
},
"home": {
"title": "YourPart - Community, Chat, Forum, Blogs, Vokabeltrainer und Spiele",
"description": "YourPart ist eine Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, dem Browser-Aufbauspiel Falukant und Minispielen.",
"keywords": "YourPart, Community, Chat, Forum, Blogs, Vokabeltrainer, Browsergame, Falukant, Minispiele, kostenlos, privat",
"jsonLdDescription": "Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen."
},
"falukant": {
"title": "Falukant - Mittelalterliches Browser-Aufbauspiel auf YourPart",
"description": "Falukant ist das mittelalterliche Browser-Aufbauspiel auf YourPart mit Handel, Politik, Familie, Bildung und Charakterentwicklung.",
"keywords": "Falukant, Browsergame, Aufbauspiel, Mittelalterspiel, Wirtschaftsspiel, Politikspiel, YourPart, kostenlos",
"jsonLdDescription": "Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.",
"jsonLdName": "Falukant"
},
"minigames": {
"title": "Minispiele auf YourPart - Match 3 und Taxi im Browser",
"description": "Entdecke die Browser-Minispiele auf YourPart: Match 3 und Taxi bieten schnelle Spielrunden direkt auf der Plattform.",
"keywords": "Minispiele, Browsergames, Match 3, Taxi Spiel, Casual Games, YourPart, kostenlos",
"jsonLdDescription": "Browser-Minispiele auf YourPart mit Match 3 und Taxi.",
"jsonLdCollectionName": "YourPart Minispiele"
},
"vocab": {
"title": "Vokabeltrainer auf YourPart - Sprachen online lernen",
"description": "Der Vokabeltrainer auf YourPart unterstützt dich beim Sprachenlernen mit interaktiven Lektionen, Kursen und Übungen.",
"keywords": "Vokabeltrainer, Sprachen lernen, Online lernen, Sprachkurse, Übungen, YourPart, kostenlos",
"jsonLdDescription": "Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.",
"jsonLdName": "YourPart Vokabeltrainer"
},
"blogList": {
"title": "Blogs auf YourPart - Community-Beiträge und Themen",
"description": "Entdecke öffentliche Blogs auf YourPart mit Community-Beiträgen, Gedanken, Erfahrungen und Themen aus verschiedenen Bereichen.",
"keywords": "Blogs, Community Blog, Artikel, Beiträge, YourPart",
"jsonLdDescription": "Öffentliche Blogs und Community-Beiträge auf YourPart.",
"jsonLdName": "Blogs auf YourPart"
},
"blogPage": {
"title": "Blogs auf YourPart",
"description": "Öffentliche Blogs, Beiträge und Community-Inhalte auf YourPart.",
"keywords": "Blog, YourPart, Community"
},
"blogPost": {
"pageTitle": "{title} | Blog auf YourPart"
}
}
}

View File

@@ -0,0 +1,51 @@
{
"seo": {
"default": {
"title": "YourPart - Community, chat, forum, vocabulary trainer, Falukant and minigames",
"description": "YourPart brings together community, chat, forums, blogs, a vocabulary trainer, the browser builder game Falukant and minigames in one platform.",
"keywords": "YourPart, community, chat, forum, blog, vocabulary trainer, Falukant, minigames, free, private, privacy"
},
"home": {
"title": "YourPart - Community, chat, forum, blogs, vocabulary trainer and games",
"description": "YourPart is a community platform with chat, forums, blogs, a vocabulary trainer, the browser builder game Falukant and minigames.",
"keywords": "YourPart, community, chat, forum, blogs, vocabulary trainer, browser game, Falukant, minigames, free, private",
"jsonLdDescription": "Community platform with chat, forums, blogs, vocabulary trainer, Falukant and browser minigames."
},
"falukant": {
"title": "Falukant - Medieval browser builder game on YourPart",
"description": "Falukant is the medieval browser builder game on YourPart with trade, politics, family, education and character progression.",
"keywords": "Falukant, browser game, builder game, medieval game, economy game, politics game, YourPart, free",
"jsonLdDescription": "Medieval browser builder game with trade, politics, family and character progression.",
"jsonLdName": "Falukant"
},
"minigames": {
"title": "Minigames on YourPart - Match 3 and taxi in the browser",
"description": "Discover browser minigames on YourPart: Match 3 and taxi offer quick rounds directly on the platform.",
"keywords": "minigames, browser games, Match 3, taxi game, casual games, YourPart, free",
"jsonLdDescription": "Browser minigames on YourPart with Match 3 and taxi.",
"jsonLdCollectionName": "YourPart minigames"
},
"vocab": {
"title": "Vocabulary trainer on YourPart - learn languages online",
"description": "The YourPart vocabulary trainer helps you learn languages with interactive lessons, courses and exercises.",
"keywords": "vocabulary trainer, learn languages, online learning, language courses, exercises, YourPart, free",
"jsonLdDescription": "Interactive vocabulary trainer with courses, lessons and exercises for language learning.",
"jsonLdName": "YourPart vocabulary trainer"
},
"blogList": {
"title": "Blogs on YourPart - community posts and topics",
"description": "Explore public blogs on YourPart with community posts, ideas, experiences and topics from many areas.",
"keywords": "blogs, community blog, articles, posts, YourPart",
"jsonLdDescription": "Public blogs and community posts on YourPart.",
"jsonLdName": "Blogs on YourPart"
},
"blogPage": {
"title": "Blogs on YourPart",
"description": "Public blogs, posts and community content on YourPart.",
"keywords": "blog, YourPart, community"
},
"blogPost": {
"pageTitle": "{title} | Blog on YourPart"
}
}
}

View File

@@ -0,0 +1,51 @@
{
"seo": {
"default": {
"title": "YourPart - Comunidad, chat, foro, vocabulario, Falukant y minijuegos",
"description": "YourPart reúne comunidad, chat, foros, blogs, un entrenador de vocabulario, el juego de construcción Falukant y minijuegos en un navegador.",
"keywords": "YourPart, comunidad, chat, foro, blog, vocabulario, Falukant, minijuegos, gratis, privado, privacidad"
},
"home": {
"title": "YourPart - Comunidad, chat, foro, blogs, vocabulario y juegos",
"description": "YourPart es una plataforma comunitaria con chat, foro, blogs, entrenador de vocabulario, el juego de construcción Falukant y minijuegos.",
"keywords": "YourPart, comunidad, chat, foro, blogs, vocabulario, juego navegador, Falukant, minijuegos, gratis, privado",
"jsonLdDescription": "Plataforma comunitaria con chat, foro, blogs, vocabulario, Falukant y minijuegos en el navegador."
},
"falukant": {
"title": "Falukant - Juego de construcción medieval en el navegador en YourPart",
"description": "Falukant es el juego de construcción medieval en YourPart con comercio, política, familia, educación y progresión del personaje.",
"keywords": "Falukant, juego navegador, construcción, medieval, economía, política, YourPart, gratis",
"jsonLdDescription": "Juego de construcción medieval en el navegador con comercio, política, familia y progresión.",
"jsonLdName": "Falukant"
},
"minigames": {
"title": "Minijuegos en YourPart - Match 3 y taxi en el navegador",
"description": "Descubre minijuegos en el navegador en YourPart: Match 3 y taxi ofrecen partidas rápidas en la plataforma.",
"keywords": "minijuegos, juegos navegador, Match 3, taxi, casual, YourPart, gratis",
"jsonLdDescription": "Minijuegos en el navegador en YourPart con Match 3 y taxi.",
"jsonLdCollectionName": "Minijuegos YourPart"
},
"vocab": {
"title": "Entrenador de vocabulario en YourPart - aprende idiomas online",
"description": "El entrenador de vocabulario de YourPart te ayuda a aprender idiomas con lecciones interactivas, cursos y ejercicios.",
"keywords": "vocabulario, aprender idiomas, online, cursos, ejercicios, YourPart, gratis",
"jsonLdDescription": "Entrenador de vocabulario interactivo con cursos, lecciones y ejercicios.",
"jsonLdName": "Vocabulario YourPart"
},
"blogList": {
"title": "Blogs en YourPart - aportes y temas de la comunidad",
"description": "Descubre blogs públicos en YourPart con aportes, ideas, experiencias y temas de distintas áreas.",
"keywords": "blogs, blog comunitario, artículos, entradas, YourPart",
"jsonLdDescription": "Blogs públicos y aportes de la comunidad en YourPart.",
"jsonLdName": "Blogs en YourPart"
},
"blogPage": {
"title": "Blogs en YourPart",
"description": "Blogs públicos, entradas y contenido comunitario en YourPart.",
"keywords": "blog, YourPart, comunidad"
},
"blogPost": {
"pageTitle": "{title} | Blog en YourPart"
}
}
}

View File

@@ -4,6 +4,7 @@ import store from './store';
import router from './router';
import './assets/styles.scss';
import i18n from './i18n';
import { setSeoI18nAccessor, applyRouteSeo } from './utils/seo';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
@@ -53,7 +54,28 @@ function getBrowserLanguage() {
const SUPPORTED_UI_LOCALES = ['de', 'en', 'ceb', 'es'];
function readLangFromUrl() {
try {
const q = new URLSearchParams(window.location.search).get('lang');
if (q && SUPPORTED_UI_LOCALES.includes(q)) {
return q;
}
} catch (_) {
/* ignore */
}
return null;
}
function getInitialAppLanguage() {
const fromUrl = readLangFromUrl();
if (fromUrl) {
try {
localStorage.setItem('uiLanguage', fromUrl);
} catch (_) {
/* ignore */
}
return fromUrl;
}
try {
const saved = localStorage.getItem('uiLanguage');
if (saved && SUPPORTED_UI_LOCALES.includes(saved)) {
@@ -72,6 +94,19 @@ const vuetify = createVuetify({
store.dispatch('setLanguage', getInitialAppLanguage());
setSeoI18nAccessor(() => ({
t: (...args) => i18n.global.t(...args),
te: (...args) => i18n.global.te(...args),
locale: store.state.language,
}));
store.watch(
(state) => state.language,
() => {
applyRouteSeo(router.currentRoute.value);
}
);
const app = createApp(App);
app.use(store);

View File

@@ -1,7 +1,6 @@
const BlogListView = () => import('@/views/blog/BlogListView.vue');
const BlogView = () => import('@/views/blog/BlogView.vue');
const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
import { buildAbsoluteUrl } from '@/utils/seo.js';
export default [
{ path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } },
@@ -14,8 +13,7 @@ export default [
props: route => ({ slug: route.params.slug }),
meta: {
seo: {
title: 'Blogs auf YourPart',
description: 'Öffentliche Blogs, Beiträge und Community-Inhalte auf YourPart.',
i18nKey: 'blogPage',
},
},
},
@@ -27,8 +25,7 @@ export default [
props: true,
meta: {
seo: {
title: 'Blogs auf YourPart',
description: 'Öffentliche Blogs, Beiträge und Community-Inhalte auf YourPart.',
i18nKey: 'blogPage',
},
},
},
@@ -38,20 +35,8 @@ export default [
component: BlogListView,
meta: {
seo: {
title: 'Blogs auf YourPart - Community-Beiträge und Themen',
description: 'Entdecke öffentliche Blogs auf YourPart mit Community-Beiträgen, Gedanken, Erfahrungen und Themen aus verschiedenen Bereichen.',
keywords: 'Blogs, Community Blog, Artikel, Beiträge, YourPart',
i18nKey: 'blogList',
canonicalPath: '/blogs',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Blogs auf YourPart',
url: buildAbsoluteUrl('/blogs'),
description: 'Öffentliche Blogs und Community-Beiträge auf YourPart.',
inLanguage: 'de',
},
],
},
},
},

View File

@@ -9,7 +9,7 @@ import blogRoutes from './blogRoutes';
import minigamesRoutes from './minigamesRoutes';
import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes';
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
import { applyRouteSeo } from '../utils/seo';
import apiClient from '../utils/axios';
const HomeView = () => import('../views/HomeView.vue');
@@ -21,25 +21,8 @@ const routes = [
component: HomeView,
meta: {
seo: {
title: 'YourPart - Community, Chat, Forum, Blogs, Vokabeltrainer und Spiele',
description: 'YourPart ist eine Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, dem Browser-Aufbauspiel Falukant und Minispielen.',
keywords: 'YourPart, Community, Chat, Forum, Blogs, Vokabeltrainer, Browsergame, Falukant, Minispiele',
i18nKey: 'home',
canonicalPath: '/',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'YourPart',
url: buildAbsoluteUrl('/'),
inLanguage: 'de',
description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.',
potentialAction: {
'@type': 'SearchAction',
target: `${buildAbsoluteUrl('/blogs')}?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
},
],
},
},
},

View File

@@ -1,5 +1,3 @@
import { buildAbsoluteUrl } from '../utils/seo';
const FalukantLandingView = () => import('../views/public/FalukantLandingView.vue');
const MinigamesLandingView = () => import('../views/public/MinigamesLandingView.vue');
const VocabLandingView = () => import('../views/public/VocabLandingView.vue');
@@ -11,26 +9,8 @@ const marketingRoutes = [
component: FalukantLandingView,
meta: {
seo: {
title: 'Falukant - Mittelalterliches Browser-Aufbauspiel auf YourPart',
description: 'Falukant ist das mittelalterliche Browser-Aufbauspiel auf YourPart mit Handel, Politik, Familie, Bildung und Charakterentwicklung.',
keywords: 'Falukant, Browsergame, Aufbauspiel, Mittelalterspiel, Wirtschaftsspiel, Politikspiel, YourPart',
i18nKey: 'falukant',
canonicalPath: '/falukant',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'VideoGame',
name: 'Falukant',
url: buildAbsoluteUrl('/falukant'),
description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.',
gamePlatform: 'Web Browser',
applicationCategory: 'Game',
inLanguage: 'de',
publisher: {
'@type': 'Organization',
name: 'YourPart',
},
},
],
},
},
},
@@ -40,20 +20,8 @@ const marketingRoutes = [
component: MinigamesLandingView,
meta: {
seo: {
title: 'Minispiele auf YourPart - Match 3 und Taxi im Browser',
description: 'Entdecke die Browser-Minispiele auf YourPart: Match 3 und Taxi bieten schnelle Spielrunden direkt auf der Plattform.',
keywords: 'Minispiele, Browsergames, Match 3, Taxi Spiel, Casual Games, YourPart',
i18nKey: 'minigames',
canonicalPath: '/minigames',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'YourPart Minispiele',
url: buildAbsoluteUrl('/minigames'),
description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.',
inLanguage: 'de',
},
],
},
},
},
@@ -63,22 +31,8 @@ const marketingRoutes = [
component: VocabLandingView,
meta: {
seo: {
title: 'Vokabeltrainer auf YourPart - Sprachen online lernen',
description: 'Der Vokabeltrainer auf YourPart unterstützt dich beim Sprachenlernen mit interaktiven Lektionen, Kursen und Übungen.',
keywords: 'Vokabeltrainer, Sprachen lernen, Online lernen, Sprachkurse, Übungen, YourPart',
i18nKey: 'vocab',
canonicalPath: '/vokabeltrainer',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'YourPart Vokabeltrainer',
url: buildAbsoluteUrl('/vokabeltrainer'),
description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.',
applicationCategory: 'EducationalApplication',
operatingSystem: 'Web',
inLanguage: 'de',
},
],
},
},
},

View File

@@ -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();
}

View File

@@ -46,7 +46,16 @@
import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
import DOMPurify from 'dompurify';
import RichTextEditor from './components/RichTextEditor.vue';
import { applySeo, buildAbsoluteUrl, createBlogSlug, stripHtml, truncateText } from '@/utils/seo.js';
import {
applySeo,
buildAbsoluteUrl,
createBlogSlug,
stripHtml,
truncateText,
seoSchemaLang,
seoOgLocale,
seoHtmlLang,
} from '@/utils/seo.js';
export default {
name: 'BlogView',
props: { id: String, slug: String },
@@ -92,13 +101,20 @@ export default {
const summarySource = this.blog.description || plainTextPosts || this.$t('blog.view.fallbackDescription');
const description = truncateText(summarySource, 160);
const canonicalPath = this.canonicalBlogPath();
const uiLang = this.$store.state.language;
const pageTitle = this.$te('seo.blogPost.pageTitle')
? this.$t('seo.blogPost.pageTitle', { title: this.blog.title })
: `${this.blog.title} | YourPart`;
applySeo({
title: `${this.blog.title} | Blog auf YourPart`,
title: pageTitle,
description,
canonicalPath,
keywords: `Blog, ${this.blog.title}, ${this.blog.owner?.username || 'YourPart'}, Community`,
type: 'article',
locale: seoOgLocale(uiLang),
lang: seoHtmlLang(uiLang),
includeHreflangAlternates: true,
jsonLd: [
{
'@context': 'https://schema.org',
@@ -106,7 +122,7 @@ export default {
name: this.blog.title,
description,
url: buildAbsoluteUrl(canonicalPath),
inLanguage: 'de',
inLanguage: seoSchemaLang(uiLang),
author: this.blog.owner?.username
? {
'@type': 'Person',
@@ -150,6 +166,9 @@ export default {
description: this.$t('blog.view.notFoundDescription'),
canonicalPath: '/blogs',
robots: 'noindex, nofollow',
locale: seoOgLocale(this.$store.state.language),
lang: seoHtmlLang(this.$store.state.language),
includeHreflangAlternates: true,
});
} finally { this.loading = false; }
},

View File

@@ -44,10 +44,14 @@ echo ""
echo "=== Building and Updating Frontend ==="
./update-frontend.sh "$TARGET_DIR"
echo ""
echo "Link backend .env"
ln -s backend/.env .env
echo ""
echo "=== Update Completed! ==="
echo "Your application has been updated."
echo ""
echo "To check logs:"
echo " Backend: sudo journalctl -u yourpart.service -f"
echo " Apache: sudo tail -f /var/log/apache2/yourpart.*.log"
echo " Apache: sudo tail -f /var/log/apache2/yourpart.*.log"