feat: add complete SEO metadata and indexing controls

This commit is contained in:
itsrubberduck
2026-07-18 21:29:31 +02:00
parent db1155c8c9
commit b87884a608
3 changed files with 285 additions and 1 deletions

View File

@@ -6,9 +6,22 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useHotjar, useRoute, useState } from '#imports';
import { useHead, useHotjar, useRoute, useState } from '#imports';
import { useAuthStore } from '~/stores/auth';
import { HOTJAR_LOCAL_STORAGE_KEY, useCookieConsent } from '~/composables/useCookieConsent';
import {
absoluteSiteUrl,
DE_FAQ_SCHEMA,
DEFAULT_OG_IMAGE,
FAQ_SCHEMA,
getLanguageAlternates,
getPublicSeoRoute,
isNewsArticlePath,
jsonLd,
normalizeSeoPath,
SITE_NAME,
SOFTWARE_APPLICATION_SCHEMA,
} from '~~/shared/utils/seo';
const HOTJAR_ID = 6522897;
const HOTJAR_SCRIPT_VERSION = 6;
@@ -28,6 +41,55 @@ const { initialize } = useHotjar();
const hotjarInitialized = useState('hotjar-initialized', () => false);
const productSession = useState<{ product: 'classroom' | 'liveatc'; path: string; startedAt: number } | null>('product-usage-session', () => null);
useHead(() => {
const path = normalizeSeoPath(route.path);
const seo = getPublicSeoRoute(path);
const isArticle = isNewsArticlePath(path);
const indexable = Boolean(seo || isArticle);
const canonical = absoluteSiteUrl(path);
const title = seo?.title || (isArticle ? `News | ${SITE_NAME}` : SITE_NAME);
const description = seo?.description || (isArticle ? `Product news and updates from ${SITE_NAME}.` : 'OpenSquawk application area.');
const image = absoluteSiteUrl(seo?.image || DEFAULT_OG_IMAGE);
const language = seo?.language || 'en';
const alternates = getLanguageAlternates(path);
return {
htmlAttrs: { lang: language },
title,
link: indexable
? [
{ rel: 'canonical', href: canonical },
...alternates.map(({ hreflang, href }) => ({ rel: 'alternate', hreflang, href })),
]
: [],
meta: [
{ name: 'robots', content: indexable ? 'index, follow, max-image-preview:large' : 'noindex, nofollow' },
{ name: 'googlebot', content: indexable ? 'index, follow, max-image-preview:large' : 'noindex, nofollow' },
{ name: 'description', content: description },
{ property: 'og:site_name', content: SITE_NAME },
{ property: 'og:type', content: isArticle ? 'article' : 'website' },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:url', content: canonical },
{ property: 'og:image', content: image },
{ property: 'og:image:width', content: '1536' },
{ property: 'og:image:height', content: '1024' },
{ property: 'og:image:alt', content: `${SITE_NAME} flight simulator training` },
{ property: 'og:locale', content: language === 'de' ? 'de_DE' : 'en_US' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: title },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: image },
],
script: path === '/' || path === '/de'
? [
{ key: 'software-application-schema', type: 'application/ld+json', innerHTML: jsonLd(SOFTWARE_APPLICATION_SCHEMA) },
{ key: 'faq-schema', type: 'application/ld+json', innerHTML: jsonLd(path === '/de' ? DE_FAQ_SCHEMA : FAQ_SCHEMA) },
]
: [],
};
});
const showCookieBanner = computed(() => {
if (!authStore.isAuthenticated) {
return true;

View File

@@ -34,6 +34,27 @@ export default defineNuxtConfig({
},
aos: {once: true, duration: 600, easing: 'ease-out'},
app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/icon-sm.jpeg'}]}},
routeRules: {
'/app/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/admin/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/bridge/connect': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/classroom/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/classroom-introduction': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/copilot': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/dev-login': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/editor/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/flightlab/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/forgot-password': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/invite': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/live-atc': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/login': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/logout': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/pilot-profile-setup': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/pm': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/present': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/start': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
'/unsubscribe': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } },
},
runtimeConfig: {
openaiKey: process.env.OPENAI_API_KEY,
openaiProject: process.env.OPENAI_PROJECT,
@@ -111,6 +132,9 @@ export default defineNuxtConfig({
'~/assets/css/learn-theme.css'
],
nitro: {
serverAssets: [
{ baseName: 'news', dir: '../content/news' },
],
experimental: {
websocket: true,
},

198
shared/utils/seo.ts Normal file
View File

@@ -0,0 +1,198 @@
export const SITE_URL = 'https://opensquawk.de'
export const SITE_NAME = 'OpenSquawk'
export const DEFAULT_OG_IMAGE = '/img/learn/missions/full-flight/briefing-hero.png'
export interface SeoRoute {
path: string
title: string
description: string
image: string
changefreq: 'daily' | 'weekly' | 'monthly' | 'yearly'
priority: number
language: 'en' | 'de'
}
export const PUBLIC_SEO_ROUTES: SeoRoute[] = [
{
path: '/',
title: 'Train ATC before you fly VATSIM | OpenSquawk',
description: 'Practice realistic ATC phraseology with guided scenarios, instant feedback and structured radio training for VATSIM and flight simulation.',
image: '/img/learn/missions/full-flight/briefing-hero.png',
changefreq: 'weekly',
priority: 1,
language: 'en',
},
{
path: '/de',
title: 'ATC-Funk für VATSIM trainieren | OpenSquawk',
description: 'Trainiere realistische ATC-Phraseologie mit geführten Szenarien, direktem Feedback und strukturierten Funkübungen für VATSIM und Flugsimulation.',
image: '/img/learn/missions/full-flight/briefing-hero.png',
changefreq: 'weekly',
priority: 0.9,
language: 'de',
},
{
path: '/news',
title: 'News, Releases & Product Updates | OpenSquawk',
description: 'Read OpenSquawk release notes, alpha progress, simulator integration updates and product decisions.',
image: '/img/learn/missions/full-flight/briefing-departure.png',
changefreq: 'weekly',
priority: 0.8,
language: 'en',
},
{
path: '/roadmap',
title: 'Product Roadmap & Community Voting | OpenSquawk',
description: 'Explore the OpenSquawk roadmap, vote on upcoming ATC training features and share ideas with the community.',
image: '/img/landing/feedback.jpeg',
changefreq: 'weekly',
priority: 0.7,
language: 'en',
},
{
path: '/bridge',
title: 'Flight Simulator Bridge Downloads | OpenSquawk',
description: 'Download the OpenSquawk Bridge and connect Microsoft Flight Simulator, FlightGear or X-Plane to your radio training.',
image: '/img/bridge/goldengate_front.jpeg',
changefreq: 'monthly',
priority: 0.7,
language: 'en',
},
{
path: '/feedback',
title: 'Feedback & Product Ideas | OpenSquawk',
description: 'Share feedback, report friction and suggest improvements for OpenSquawk ATC training and flight simulator integrations.',
image: '/img/landing/feedback.jpeg',
changefreq: 'monthly',
priority: 0.5,
language: 'en',
},
{
path: '/api-docs',
title: 'API Documentation | OpenSquawk',
description: 'OpenSquawk API reference for waitlist, updates, roadmap, feedback and authentication integrations.',
image: '/img/landing/simulator.jpeg',
changefreq: 'monthly',
priority: 0.5,
language: 'en',
},
{
path: '/impressum',
title: 'Imprint | OpenSquawk',
description: 'Legal provider information and contact details for OpenSquawk.',
image: DEFAULT_OG_IMAGE,
changefreq: 'yearly',
priority: 0.2,
language: 'en',
},
{
path: '/datenschutz',
title: 'Privacy Policy | OpenSquawk',
description: 'Learn how OpenSquawk collects, processes and protects personal data.',
image: DEFAULT_OG_IMAGE,
changefreq: 'yearly',
priority: 0.2,
language: 'en',
},
{
path: '/agb',
title: 'Terms of Service | OpenSquawk',
description: 'Terms governing registration and use of the OpenSquawk simulator training platform.',
image: DEFAULT_OG_IMAGE,
changefreq: 'yearly',
priority: 0.2,
language: 'en',
},
]
export const isNewsArticlePath = (path: string) => /^\/news\/[^/]+\/?$/.test(path)
export const normalizeSeoPath = (path: string) => {
if (path === '/') return path
return path.replace(/\/+$/, '') || '/'
}
export const getPublicSeoRoute = (path: string) => {
const normalized = normalizeSeoPath(path)
return PUBLIC_SEO_ROUTES.find((route) => route.path === normalized)
}
export const absoluteSiteUrl = (path = '/') => new URL(path, `${SITE_URL}/`).toString()
export const getLanguageAlternates = (path: string) => {
const normalized = normalizeSeoPath(path)
if (normalized === '/' || normalized === '/de') {
return [
{ hreflang: 'en', href: absoluteSiteUrl('/') },
{ hreflang: 'de', href: absoluteSiteUrl('/de') },
{ hreflang: 'x-default', href: absoluteSiteUrl('/') },
]
}
const canonical = absoluteSiteUrl(normalized)
return [
{ hreflang: 'en', href: canonical },
{ hreflang: 'x-default', href: canonical },
]
}
export const FAQ_SCHEMA = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
['Is Classroom free right now?', 'Yes. Classroom is currently free during the invitation alpha.'],
['What is the difference between Classroom and Live ATC?', 'Classroom is structured listening, readback and feedback training. Live ATC is the real-time simulator radio loop and is still in closed alpha.'],
['Do I need to be on VATSIM to use it?', 'No. OpenSquawk is built for VATSIM preparation, so you can train before flying live on network frequencies.'],
['Can I self-host and inspect the stack?', 'Yes. The core is open source and self-hosting remains free.'],
['How do I get access?', 'Join the waitlist for Classroom and Live ATC. Access is released in rolling batches.'],
['Is this for real-world aviation?', 'No. OpenSquawk is simulator training software and is not intended for real-world ATC communication.'],
].map(([name, text]) => ({
'@type': 'Question',
name,
acceptedAnswer: { '@type': 'Answer', text },
})),
}
export const DE_FAQ_SCHEMA = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
['Ist Classroom aktuell kostenlos?', 'Ja. Classroom ist während der Einladungs-Alpha aktuell kostenlos.'],
['Was ist der Unterschied zwischen Classroom und Live ATC?', 'Classroom bietet strukturierte Hör-, Readback- und Feedbackübungen. Live ATC ist der Echtzeit-Funk für den Flugsimulator und befindet sich in der geschlossenen Alpha.'],
['Muss ich bei VATSIM angemeldet sein?', 'Nein. OpenSquawk bereitet dich auf VATSIM vor, damit du vor dem ersten Onlineflug sicher üben kannst.'],
['Kann ich OpenSquawk selbst hosten?', 'Ja. Der Kern ist Open Source und kann kostenlos selbst gehostet werden.'],
['Wie erhalte ich Zugang?', 'Trage dich in die Warteliste für Classroom und Live ATC ein. Einladungen werden schrittweise freigeschaltet.'],
['Ist OpenSquawk für die reale Luftfahrt gedacht?', 'Nein. OpenSquawk ist Trainingssoftware für Flugsimulatoren und nicht für echten Flugfunk vorgesehen.'],
].map(([name, text]) => ({
'@type': 'Question',
name,
acceptedAnswer: { '@type': 'Answer', text },
})),
}
export const SOFTWARE_APPLICATION_SCHEMA = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: SITE_NAME,
url: absoluteSiteUrl('/'),
applicationCategory: 'EducationalApplication',
applicationSubCategory: 'Flight simulator ATC training',
operatingSystem: 'Web',
description: 'Structured ATC radio and phraseology training for flight simulator pilots preparing for VATSIM.',
image: absoluteSiteUrl(DEFAULT_OG_IMAGE),
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'EUR',
availability: 'https://schema.org/LimitedAvailability',
},
featureList: [
'Guided ATC communication scenarios',
'Readback practice and instant feedback',
'Flight simulator integration',
'Self-hosted open-source option',
],
}
export const jsonLd = (value: object) => JSON.stringify(value).replaceAll('<', '\\u003c')