mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 12:05:33 +08:00
feat: add complete SEO metadata and indexing controls
This commit is contained in:
64
app/app.vue
64
app/app.vue
@@ -6,9 +6,22 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
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 { useAuthStore } from '~/stores/auth';
|
||||||
import { HOTJAR_LOCAL_STORAGE_KEY, useCookieConsent } from '~/composables/useCookieConsent';
|
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_ID = 6522897;
|
||||||
const HOTJAR_SCRIPT_VERSION = 6;
|
const HOTJAR_SCRIPT_VERSION = 6;
|
||||||
@@ -28,6 +41,55 @@ const { initialize } = useHotjar();
|
|||||||
const hotjarInitialized = useState('hotjar-initialized', () => false);
|
const hotjarInitialized = useState('hotjar-initialized', () => false);
|
||||||
const productSession = useState<{ product: 'classroom' | 'liveatc'; path: string; startedAt: number } | null>('product-usage-session', () => null);
|
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(() => {
|
const showCookieBanner = computed(() => {
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
aos: {once: true, duration: 600, easing: 'ease-out'},
|
aos: {once: true, duration: 600, easing: 'ease-out'},
|
||||||
app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/icon-sm.jpeg'}]}},
|
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: {
|
runtimeConfig: {
|
||||||
openaiKey: process.env.OPENAI_API_KEY,
|
openaiKey: process.env.OPENAI_API_KEY,
|
||||||
openaiProject: process.env.OPENAI_PROJECT,
|
openaiProject: process.env.OPENAI_PROJECT,
|
||||||
@@ -111,6 +132,9 @@ export default defineNuxtConfig({
|
|||||||
'~/assets/css/learn-theme.css'
|
'~/assets/css/learn-theme.css'
|
||||||
],
|
],
|
||||||
nitro: {
|
nitro: {
|
||||||
|
serverAssets: [
|
||||||
|
{ baseName: 'news', dir: '../content/news' },
|
||||||
|
],
|
||||||
experimental: {
|
experimental: {
|
||||||
websocket: true,
|
websocket: true,
|
||||||
},
|
},
|
||||||
|
|||||||
198
shared/utils/seo.ts
Normal file
198
shared/utils/seo.ts
Normal 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')
|
||||||
Reference in New Issue
Block a user