SEO changes
This commit is contained in:
+15
-2
@@ -6,6 +6,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#e82517" />
|
<meta name="theme-color" content="#e82517" />
|
||||||
|
<meta name="application-name" content="Toothless' TSS Bot" />
|
||||||
<style>
|
<style>
|
||||||
html { background: #130d08; }
|
html { background: #130d08; }
|
||||||
html[data-theme="dark"] { background: #130d08; color-scheme: dark; }
|
html[data-theme="dark"] { background: #130d08; color-scheme: dark; }
|
||||||
@@ -105,25 +106,37 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="keywords"
|
name="keywords"
|
||||||
content="TSS Bot, Toothless TSS Bot, War Thunder TSS, TSS leaderboard, TSS battle logs, TSS teams"
|
content="War Thunder TSS, Tournament Service, TSS leaderboard, TSS teams, TSS battle logs, War Thunder tournaments, Toothless TSS Bot"
|
||||||
/>
|
/>
|
||||||
<meta name="author" content="Toothless' TSS Bot" />
|
<meta name="author" content="Toothless' TSS Bot" />
|
||||||
<meta name="robots" content="__SEO_ROBOTS__" />
|
<meta name="robots" content="__SEO_ROBOTS__" />
|
||||||
<link rel="canonical" href="__SEO_CANONICAL__" />
|
<link rel="canonical" href="__SEO_CANONICAL__" />
|
||||||
|
|
||||||
<meta property="og:site_name" content="Toothless' TSS Bot" />
|
<meta property="og:site_name" content="Toothless' TSS Bot" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:locale" content="en_GB" />
|
||||||
|
<meta property="og:type" content="__SEO_OG_TYPE__" />
|
||||||
<meta property="og:url" content="__SEO_CANONICAL__" />
|
<meta property="og:url" content="__SEO_CANONICAL__" />
|
||||||
<meta property="og:title" content="__SEO_TITLE__" />
|
<meta property="og:title" content="__SEO_TITLE__" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="__SEO_DESCRIPTION__"
|
content="__SEO_DESCRIPTION__"
|
||||||
/>
|
/>
|
||||||
|
<meta property="og:image" content="__SEO_IMAGE__" />
|
||||||
|
<meta property="og:image:secure_url" content="__SEO_IMAGE__" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Toothless' TSS Bot War Thunder TSS dashboard" />
|
||||||
|
<meta property="article:author" content="__SEO_AUTHOR__" />
|
||||||
|
<meta property="article:published_time" content="__SEO_PUBLISHED_TIME__" />
|
||||||
|
<meta property="article:modified_time" content="__SEO_MODIFIED_TIME__" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="__SEO_TITLE__" />
|
<meta name="twitter:title" content="__SEO_TITLE__" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="__SEO_DESCRIPTION__"
|
content="__SEO_DESCRIPTION__"
|
||||||
/>
|
/>
|
||||||
|
<meta name="twitter:image" content="__SEO_IMAGE__" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/embed-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/embed-icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/embed-icon.svg" />
|
<link rel="apple-touch-icon" href="/embed-icon.svg" />
|
||||||
<script type="application/ld+json" id="site-structured-data">
|
<script type="application/ld+json" id="site-structured-data">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
+216
-90
@@ -57,6 +57,9 @@ const themePreferenceKey = 'tssbot.theme'
|
|||||||
const themePreferenceCookie = 'tssbot_theme'
|
const themePreferenceCookie = 'tssbot_theme'
|
||||||
const liveRefreshMs = 15000
|
const liveRefreshMs = 15000
|
||||||
const siteVersion = import.meta.env.VITE_SITE_VERSION || '1.0.0'
|
const siteVersion = import.meta.env.VITE_SITE_VERSION || '1.0.0'
|
||||||
|
const seoImagePath = '/embed.png'
|
||||||
|
const indexRobots = 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1'
|
||||||
|
const noindexRobots = 'noindex, follow'
|
||||||
|
|
||||||
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||||
const siteGateSetting = import.meta.env.VITE_SITE_GATE
|
const siteGateSetting = import.meta.env.VITE_SITE_GATE
|
||||||
@@ -893,6 +896,14 @@ function canonicalPathForRoute(route) {
|
|||||||
return '/'
|
return '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seoImageUrl() {
|
||||||
|
return `${currentPublicOrigin()}${seoImagePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGraphType(seo) {
|
||||||
|
return seo.type === 'BlogPosting' ? 'article' : 'website'
|
||||||
|
}
|
||||||
|
|
||||||
function seoForRoute(route, profileDetail = null) {
|
function seoForRoute(route, profileDetail = null) {
|
||||||
const teamName =
|
const teamName =
|
||||||
route.page === 'team'
|
route.page === 'team'
|
||||||
@@ -908,116 +919,134 @@ function seoForRoute(route, profileDetail = null) {
|
|||||||
teamBattles ? `${formatNumber(teamBattles)} battles` : '',
|
teamBattles ? `${formatNumber(teamBattles)} battles` : '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
return {
|
return {
|
||||||
title: `${teamName} TSS Team Profile | Toothless' TSS Bot`,
|
title: `${teamName} War Thunder TSS Team Profile | Toothless' TSS Bot`,
|
||||||
description: stats.length
|
description: stats.length
|
||||||
? `${teamName} TSS team profile with ${stats.join(', ')}, roster details, battle history, and recent War Thunder squadron activity.`
|
? `${teamName} War Thunder TSS team profile with ${stats.join(', ')}, roster details, battle history, and recent squadron activity.`
|
||||||
: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`,
|
: `${teamName} War Thunder TSS team profile with roster details, battle history, and recent squadron activity.`,
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: canonicalPathForRoute({ ...route, teamName }),
|
path: canonicalPathForRoute({ ...route, teamName }),
|
||||||
|
type: 'ProfilePage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.page === 'player' && route.uid) {
|
if (route.page === 'player' && route.uid) {
|
||||||
return {
|
return {
|
||||||
title: `Player ${route.uid} | Toothless' TSS Bot`,
|
title: `Player ${route.uid} | Toothless' TSS Bot`,
|
||||||
description: `TSS career stats for player ${route.uid} — battles, win rate, kills, and teams seen with.`,
|
description: `War Thunder TSS career stats for player ${route.uid}: battles, win rate, kills, and teams seen with.`,
|
||||||
robots: 'noindex, follow',
|
robots: noindexRobots,
|
||||||
path: canonicalPathForRoute(route),
|
path: canonicalPathForRoute(route),
|
||||||
|
type: 'ProfilePage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.page === 'game' && route.gameId) {
|
if (route.page === 'game' && route.gameId) {
|
||||||
return {
|
return {
|
||||||
title: `Game ${route.gameId} | Toothless' TSS Bot`,
|
title: `Game ${route.gameId} | Toothless' TSS Bot`,
|
||||||
description: `TSS battle log details for game ${route.gameId}, including participants, map, player counts, and combat stats.`,
|
description: `War Thunder TSS battle log details for game ${route.gameId}, including participants, map, player counts, and combat stats.`,
|
||||||
robots: 'noindex, follow',
|
robots: noindexRobots,
|
||||||
path: canonicalPathForRoute(route),
|
path: canonicalPathForRoute(route),
|
||||||
|
type: 'WebPage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.page === 'tournament' && route.tournamentId) {
|
if (route.page === 'tournament' && route.tournamentId) {
|
||||||
return {
|
return {
|
||||||
title: `Tournament ${route.tournamentId} | Toothless' TSS Bot`,
|
title: `War Thunder TSS Tournament ${route.tournamentId} | Toothless' TSS Bot`,
|
||||||
description: `TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
description: `War Thunder TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: canonicalPathForRoute(route),
|
path: canonicalPathForRoute(route),
|
||||||
|
type: 'CollectionPage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const byPage = {
|
const byPage = {
|
||||||
teams: {
|
teams: {
|
||||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
title: "War Thunder TSS Team Leaderboard | Toothless' TSS Bot",
|
||||||
description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.',
|
description: 'Browse the live War Thunder TSS team leaderboard with squadron rankings, points, players, win rates, battles, and recent activity.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/teams',
|
path: '/teams',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
players: {
|
players: {
|
||||||
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
title: "War Thunder TSS Player Leaderboard | Toothless' TSS Bot",
|
||||||
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
description: 'Browse the War Thunder TSS player leaderboard and compare player score, kills, assists, win rate, KDR, teams, and battle activity.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/players',
|
path: '/players',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
'battle-logs': {
|
'battle-logs': {
|
||||||
title: "TSS Battle Logs | Toothless' TSS Bot",
|
title: "War Thunder TSS Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
description: 'Read recent War Thunder TSS battle logs with teams, maps, player counts, battle times, replays, and match context.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/battle-logs',
|
path: '/battle-logs',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
'tournaments-list': {
|
'tournaments-list': {
|
||||||
title: "TSS Tournaments | Toothless' TSS Bot",
|
title: "War Thunder TSS Tournaments | Brackets and Standings",
|
||||||
description: 'Browse tracked TSS tournaments with authoritative brackets, standings, and linked replay availability.',
|
description: "Browse War Thunder TSS tournaments with brackets, standings, teams, matches, and replay links tracked by Toothless' TSS Bot.",
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/tournaments',
|
path: '/tournaments',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
blog: {
|
blog: {
|
||||||
title: "TSS Bot Blog | Toothless' TSS Bot",
|
title: "War Thunder TSS Bot Blog | Toothless' TSS Bot",
|
||||||
description: 'News, updates, and announcements from Toothless TSS Bot.',
|
description: 'News, feature updates, tournament tracking notes, and War Thunder TSS Bot announcements from Toothless TSS Bot.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/blog',
|
path: '/blog',
|
||||||
|
type: 'Blog',
|
||||||
},
|
},
|
||||||
'blog-post': {
|
'blog-post': {
|
||||||
title: `${blogPostsBySlug.get(route.slug)?.title || 'Blog post'} | Toothless' TSS Bot`,
|
title: `${blogPostsBySlug.get(route.slug)?.title || 'Blog post'} | Toothless' TSS Bot`,
|
||||||
description: blogPostsBySlug.get(route.slug)?.excerpt || 'News and updates from Toothless TSS Bot.',
|
description: blogPostsBySlug.get(route.slug)?.excerpt || 'News and updates from Toothless TSS Bot.',
|
||||||
robots: blogPostsBySlug.has(route.slug) ? 'index, follow' : 'noindex, follow',
|
robots: blogPostsBySlug.has(route.slug) ? indexRobots : noindexRobots,
|
||||||
path: route.slug ? blogPostPath(route.slug) : '/blog',
|
path: route.slug ? blogPostPath(route.slug) : '/blog',
|
||||||
|
type: 'BlogPosting',
|
||||||
|
publishedAt: blogPostsBySlug.get(route.slug)?.date || '',
|
||||||
|
author: blogPostsBySlug.get(route.slug)?.author || "Toothless' TSS Bot",
|
||||||
},
|
},
|
||||||
uptime: {
|
uptime: {
|
||||||
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
||||||
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/uptime',
|
path: '/uptime',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
viewers: {
|
viewers: {
|
||||||
title: "Viewer Analytics | Toothless' TSS Bot",
|
title: "Viewer Analytics | Toothless' TSS Bot",
|
||||||
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
|
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
|
||||||
robots: 'noindex, follow',
|
robots: noindexRobots,
|
||||||
path: '/viewers',
|
path: '/viewers',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
title: "Privacy Notice | Toothless' TSS Bot",
|
title: "Privacy Notice | Toothless' TSS Bot",
|
||||||
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
|
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/privacy',
|
path: '/privacy',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
title: "Docs | Toothless' TSS Bot",
|
title: "Docs | Toothless' TSS Bot",
|
||||||
description: 'Documentation and command reference for Toothless TSS Bot.',
|
description: 'Documentation and command reference for Toothless TSS Bot.',
|
||||||
robots: 'noindex, follow',
|
robots: noindexRobots,
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings | Toothless' TSS Bot",
|
title: "Settings | Toothless' TSS Bot",
|
||||||
description: 'Customize layout and appearance preferences for Toothless TSS Bot, including custom theme accent colors.',
|
description: 'Customize layout and appearance preferences for Toothless TSS Bot, including custom theme accent colors.',
|
||||||
robots: 'noindex, nofollow',
|
robots: 'noindex, nofollow',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return byPage[route.page] || {
|
return byPage[route.page] || {
|
||||||
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
|
title: "War Thunder TSS Leaderboards, Teams and Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.',
|
description: 'Track War Thunder Tournament Service teams, live TSS leaderboards, squadron profiles, battle logs, player stats, and tournament brackets.',
|
||||||
robots: 'index, follow',
|
robots: indexRobots,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
type: 'WebApplication',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,13 +1074,18 @@ function upsertLink(rel, href) {
|
|||||||
|
|
||||||
function structuredDataForSeo(seo, canonicalUrl) {
|
function structuredDataForSeo(seo, canonicalUrl) {
|
||||||
const base = currentPublicOrigin()
|
const base = currentPublicOrigin()
|
||||||
|
const siteId = `${base}/#website`
|
||||||
|
const pageId = `${canonicalUrl}#webpage`
|
||||||
|
const imageUrl = seoImageUrl()
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
|
'@id': siteId,
|
||||||
name: "Toothless' TSS Bot",
|
name: "Toothless' TSS Bot",
|
||||||
|
alternateName: ['TSS Bot', 'War Thunder TSS Bot', 'Toothless TSS Bot'],
|
||||||
url: `${base}/`,
|
url: `${base}/`,
|
||||||
description: "Live War Thunder TSS leaderboards, battle logs, and team profiles.",
|
description: 'Live War Thunder Tournament Service leaderboards, battle logs, tournaments, and team profiles.',
|
||||||
potentialAction: {
|
potentialAction: {
|
||||||
'@type': 'SearchAction',
|
'@type': 'SearchAction',
|
||||||
target: `${base}/teams/{search_term_string}`,
|
target: `${base}/teams/{search_term_string}`,
|
||||||
@@ -1060,18 +1094,64 @@ function structuredDataForSeo(seo, canonicalUrl) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': seo.type || 'WebPage',
|
||||||
|
'@id': pageId,
|
||||||
name: seo.title,
|
name: seo.title,
|
||||||
url: canonicalUrl,
|
url: canonicalUrl,
|
||||||
description: seo.description,
|
description: seo.description,
|
||||||
|
image: imageUrl,
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@type': 'WebSite',
|
'@id': siteId,
|
||||||
name: "Toothless' TSS Bot",
|
|
||||||
url: `${base}/`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: 'Home',
|
||||||
|
item: `${base}/`,
|
||||||
|
},
|
||||||
|
...(seo.path === '/' ? [] : [{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||||
|
item: canonicalUrl,
|
||||||
|
}]),
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (seo.type === 'BlogPosting') {
|
||||||
|
items.push({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||||
|
description: seo.description,
|
||||||
|
url: canonicalUrl,
|
||||||
|
image: imageUrl,
|
||||||
|
datePublished: seo.publishedAt || undefined,
|
||||||
|
dateModified: seo.publishedAt || undefined,
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: seo.author || "Toothless' TSS Bot",
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: "Toothless' TSS Bot",
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: imageUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@id': pageId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return JSON.stringify(items)
|
return JSON.stringify(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,10 +1163,20 @@ function applySeo(route, profileDetail = null) {
|
|||||||
upsertMeta('meta[name="description"]', { name: 'description' }, 'content', seo.description)
|
upsertMeta('meta[name="description"]', { name: 'description' }, 'content', seo.description)
|
||||||
upsertMeta('meta[name="robots"]', { name: 'robots' }, 'content', seo.robots)
|
upsertMeta('meta[name="robots"]', { name: 'robots' }, 'content', seo.robots)
|
||||||
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, 'content', seo.title)
|
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, 'content', seo.title)
|
||||||
|
upsertMeta('meta[property="og:type"]', { property: 'og:type' }, 'content', openGraphType(seo))
|
||||||
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, 'content', seo.description)
|
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, 'content', seo.description)
|
||||||
upsertMeta('meta[property="og:url"]', { property: 'og:url' }, 'content', canonicalUrl)
|
upsertMeta('meta[property="og:url"]', { property: 'og:url' }, 'content', canonicalUrl)
|
||||||
|
upsertMeta('meta[property="og:image"]', { property: 'og:image' }, 'content', seoImageUrl())
|
||||||
|
upsertMeta('meta[property="og:image:secure_url"]', { property: 'og:image:secure_url' }, 'content', seoImageUrl())
|
||||||
|
upsertMeta('meta[property="og:image:type"]', { property: 'og:image:type' }, 'content', 'image/png')
|
||||||
|
upsertMeta('meta[property="og:image:width"]', { property: 'og:image:width' }, 'content', '1200')
|
||||||
|
upsertMeta('meta[property="og:image:height"]', { property: 'og:image:height' }, 'content', '630')
|
||||||
|
upsertMeta('meta[property="article:author"]', { property: 'article:author' }, 'content', seo.author || "Toothless' TSS Bot")
|
||||||
|
upsertMeta('meta[property="article:published_time"]', { property: 'article:published_time' }, 'content', seo.publishedAt || '')
|
||||||
|
upsertMeta('meta[property="article:modified_time"]', { property: 'article:modified_time' }, 'content', seo.publishedAt || '')
|
||||||
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, 'content', seo.title)
|
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, 'content', seo.title)
|
||||||
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, 'content', seo.description)
|
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, 'content', seo.description)
|
||||||
|
upsertMeta('meta[name="twitter:image"]', { name: 'twitter:image' }, 'content', seoImageUrl())
|
||||||
upsertLink('canonical', canonicalUrl)
|
upsertLink('canonical', canonicalUrl)
|
||||||
|
|
||||||
let structuredData = document.getElementById('site-structured-data')
|
let structuredData = document.getElementById('site-structured-data')
|
||||||
@@ -2227,32 +2317,38 @@ function AppContent() {
|
|||||||
className="flex max-w-[calc(100vw-2rem)] items-center gap-1.5 rounded-full border border-border bg-fury-white/92 py-1.5 pl-1.5 pr-11 shadow-[0_8px_24px_rgba(0,0,0,0.13)] backdrop-blur sm:pl-2"
|
className="flex max-w-[calc(100vw-2rem)] items-center gap-1.5 rounded-full border border-border bg-fury-white/92 py-1.5 pl-1.5 pr-11 shadow-[0_8px_24px_rgba(0,0,0,0.13)] backdrop-blur sm:pl-2"
|
||||||
ref={navPillRef}
|
ref={navPillRef}
|
||||||
>
|
>
|
||||||
<button
|
<a
|
||||||
aria-label="Go to Toothless' TSS Bot home"
|
aria-label="Go to Toothless' TSS Bot home"
|
||||||
className="hidden h-8 w-8 shrink-0 place-items-center rounded-full transition hover:bg-surface sm:grid"
|
className="hidden h-8 w-8 shrink-0 place-items-center rounded-full transition hover:bg-surface sm:grid"
|
||||||
onClick={() => navigate('/')}
|
href="/"
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt=""
|
alt=""
|
||||||
className="h-7 w-7 rounded-full"
|
className="h-7 w-7 rounded-full"
|
||||||
src="/embed-icon.svg"
|
src="/embed-icon.svg"
|
||||||
/>
|
/>
|
||||||
</button>
|
</a>
|
||||||
|
|
||||||
<nav className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
<nav className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||||
{primaryNavItems.map((item) => (
|
{primaryNavItems.map((item) => (
|
||||||
<button
|
<a
|
||||||
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 transition sm:px-3 ${activeNavPath === item.path
|
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 transition sm:px-3 ${activeNavPath === item.path
|
||||||
? 'bg-text text-bg apricot-button-text'
|
? 'bg-text text-bg apricot-button-text'
|
||||||
: 'text-text-soft hover:bg-surface hover:text-text'
|
: 'text-text-soft hover:bg-surface hover:text-text'
|
||||||
}`}
|
}`}
|
||||||
|
href={item.path}
|
||||||
key={item.path}
|
key={item.path}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault()
|
||||||
|
navigate(item.path)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -2260,17 +2356,20 @@ function AppContent() {
|
|||||||
|
|
||||||
<nav className="flex shrink-0 items-center gap-0.5">
|
<nav className="flex shrink-0 items-center gap-0.5">
|
||||||
{utilityNavItems.map((item) => (
|
{utilityNavItems.map((item) => (
|
||||||
<button
|
<a
|
||||||
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 transition ${activeNavPath === item.path
|
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 transition ${activeNavPath === item.path
|
||||||
? 'bg-text text-bg apricot-button-text'
|
? 'bg-text text-bg apricot-button-text'
|
||||||
: 'text-text-soft hover:bg-surface hover:text-text'
|
: 'text-text-soft hover:bg-surface hover:text-text'
|
||||||
}`}
|
}`}
|
||||||
|
href={item.path}
|
||||||
key={item.path}
|
key={item.path}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault()
|
||||||
|
navigate(item.path)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -3185,10 +3284,13 @@ function BlogPage({ navigate, posts }) {
|
|||||||
<div className="mt-8 grid gap-4">
|
<div className="mt-8 grid gap-4">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm" key={post.slug}>
|
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm" key={post.slug}>
|
||||||
<button
|
<a
|
||||||
className="block w-full text-left"
|
className="block w-full text-left"
|
||||||
onClick={() => navigate(post.path)}
|
href={post.path}
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate(post.path)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
{formatBlogDate(post.date)}
|
{formatBlogDate(post.date)}
|
||||||
@@ -3200,7 +3302,7 @@ function BlogPage({ navigate, posts }) {
|
|||||||
<span className="mt-4 inline-block text-sm font-semibold text-fury-cyan">
|
<span className="mt-4 inline-block text-sm font-semibold text-fury-cyan">
|
||||||
Read post
|
Read post
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -3668,27 +3770,36 @@ function Landing({
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-8 w-full max-w-xl">
|
<div className="mt-8 w-full max-w-xl">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<button
|
<a
|
||||||
className="apricot-button-text min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan"
|
className="apricot-button-text min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan"
|
||||||
onClick={() => navigate('/teams')}
|
href="/teams"
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/teams')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Team Leaderboard
|
Team Leaderboard
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
className="min-h-15 rounded-lg border-2 border-text bg-fury-white px-5 py-4 text-base font-semibold text-text transition hover:bg-surface"
|
className="min-h-15 rounded-lg border-2 border-text bg-fury-white px-5 py-4 text-base font-semibold text-text transition hover:bg-surface"
|
||||||
onClick={() => navigate('/players')}
|
href="/players"
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/players')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Player Leaderboard
|
Player Leaderboard
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
className="min-h-15 rounded-lg border-2 border-ring px-5 py-4 text-base font-semibold text-fury-cyan transition hover:bg-surface hover:text-text"
|
className="min-h-15 rounded-lg border-2 border-ring px-5 py-4 text-base font-semibold text-fury-cyan transition hover:bg-surface hover:text-text"
|
||||||
onClick={() => navigate('/battle-logs')}
|
href="/battle-logs"
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/battle-logs')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Battle Logs
|
Battle Logs
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -3719,10 +3830,13 @@ function Landing({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button
|
<a
|
||||||
className="mt-4 grid w-full gap-3 border-l-2 border-fury-cyan bg-fury-white/80 px-4 py-3 text-left shadow-sm backdrop-blur transition hover:bg-surface sm:grid-cols-[1fr_auto] sm:items-center"
|
className="mt-4 grid w-full gap-3 border-l-2 border-fury-cyan bg-fury-white/80 px-4 py-3 text-left shadow-sm backdrop-blur transition hover:bg-surface sm:grid-cols-[1fr_auto] sm:items-center"
|
||||||
onClick={() => navigate(latestBlogPost?.path || '/blog')}
|
href={latestBlogPost?.path || '/blog'}
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate(latestBlogPost?.path || '/blog')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-xs font-semibold uppercase tracking-wide text-fury-cyan">
|
<span className="block text-xs font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
@@ -3738,7 +3852,7 @@ function Landing({
|
|||||||
<span className="text-sm font-semibold text-fury-cyan">
|
<span className="text-sm font-semibold text-fury-cyan">
|
||||||
{latestBlogPost ? 'Read post' : 'Open blog'}
|
{latestBlogPost ? 'Read post' : 'Open blog'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4233,12 +4347,15 @@ function TeamsPage({ leaderboard, navigate, teams }) {
|
|||||||
{teams.map((team, index) => {
|
{teams.map((team, index) => {
|
||||||
const name = bestTeamName(team)
|
const name = bestTeamName(team)
|
||||||
return (
|
return (
|
||||||
<button
|
<a
|
||||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[4rem_1fr_repeat(4,auto)] md:items-center"
|
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[4rem_1fr_repeat(4,auto)] md:items-center"
|
||||||
key={`${name}-${team.clan_id || index}`}
|
href={teamPath(name)}
|
||||||
onClick={() => navigate(teamPath(name))}
|
key={`${name}-${team.clan_id || index}`}
|
||||||
type="button"
|
onClick={(event) => {
|
||||||
>
|
event.preventDefault()
|
||||||
|
navigate(teamPath(name))
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="text-sm font-semibold text-fury-cyan">#{index + 1}</span>
|
<span className="text-sm font-semibold text-fury-cyan">#{index + 1}</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-lg font-semibold">{name}</span>
|
<span className="block truncate text-lg font-semibold">{name}</span>
|
||||||
@@ -4249,7 +4366,7 @@ function TeamsPage({ leaderboard, navigate, teams }) {
|
|||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{formatNumber(team.points?.total_points || team.total_kills)}
|
{formatNumber(team.points?.total_points || team.total_kills)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</a>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -4293,11 +4410,14 @@ function PlayersPage({ leaderboard, navigate, players }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{players.map((player, index) => (
|
{players.map((player, index) => (
|
||||||
<button
|
<a
|
||||||
className="grid w-full grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-4 text-left text-sm transition hover:bg-surface"
|
className="grid w-full grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-4 text-left text-sm transition hover:bg-surface"
|
||||||
|
href={playerPath(player.uid)}
|
||||||
key={player.uid}
|
key={player.uid}
|
||||||
onClick={() => navigate(playerPath(player.uid))}
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault()
|
||||||
|
navigate(playerPath(player.uid))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-fury-cyan">#{index + 1}</p>
|
<p className="font-semibold text-fury-cyan">#{index + 1}</p>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -4313,7 +4433,7 @@ function PlayersPage({ leaderboard, navigate, players }) {
|
|||||||
<p className="text-right">{Number(player.win_rate || 0).toFixed(1)}%</p>
|
<p className="text-right">{Number(player.win_rate || 0).toFixed(1)}%</p>
|
||||||
<p className="text-right">{Number(player.kdr || 0).toFixed(2)}</p>
|
<p className="text-right">{Number(player.kdr || 0).toFixed(2)}</p>
|
||||||
<p className="text-right">{formatNumber(player.teams_seen)}</p>
|
<p className="text-right">{formatNumber(player.teams_seen)}</p>
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4903,11 +5023,14 @@ function BattleLogsPage({ live, matches, navigate }) {
|
|||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
{matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<button
|
<a
|
||||||
className="grid w-full gap-x-6 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)_auto] md:items-center"
|
className="grid w-full gap-x-6 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)_auto] md:items-center"
|
||||||
|
href={gamePath(match.session_id)}
|
||||||
key={match.session_id}
|
key={match.session_id}
|
||||||
onClick={() => navigate(gamePath(match.session_id))}
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault()
|
||||||
|
navigate(gamePath(match.session_id))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
|
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
|
||||||
@@ -4917,7 +5040,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
<ParticipantNames participants={gameParticipants(match)} spread />
|
<ParticipantNames participants={gameParticipants(match)} spread />
|
||||||
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
|
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!matches.length ? (
|
{!matches.length ? (
|
||||||
@@ -4997,11 +5120,14 @@ function TournamentsPage({ navigate }) {
|
|||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
{tournaments.map((tournament) => (
|
{tournaments.map((tournament) => (
|
||||||
<button
|
<a
|
||||||
className="grid w-full gap-x-4 gap-y-1 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(3,auto)] md:items-center"
|
className="grid w-full gap-x-4 gap-y-1 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(3,auto)] md:items-center"
|
||||||
|
href={tournamentPath(tournament.tournament_id)}
|
||||||
key={tournament.tournament_id}
|
key={tournament.tournament_id}
|
||||||
onClick={() => navigate(tournamentPath(tournament.tournament_id))}
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault()
|
||||||
|
navigate(tournamentPath(tournament.tournament_id))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
@@ -5019,7 +5145,7 @@ function TournamentsPage({ navigate }) {
|
|||||||
<span className="text-sm">{formatNumber(tournament.match_count)} matches</span>
|
<span className="text-sm">{formatNumber(tournament.match_count)} matches</span>
|
||||||
<span className="text-sm">{formatNumber(tournament.team_count)} teams</span>
|
<span className="text-sm">{formatNumber(tournament.team_count)} teams</span>
|
||||||
<span className="text-sm font-semibold text-fury-cyan">View</span>
|
<span className="text-sm font-semibold text-fury-cyan">View</span>
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!tournaments.length ? (
|
{!tournaments.length ? (
|
||||||
|
|||||||
+253
-32
@@ -66,6 +66,10 @@ const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
|||||||
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
||||||
const SITE_SESSION_SECRET = process.env.SITE_SESSION_SECRET || process.env.API_SESSION_SECRET || TURNSTILE_SECRET_KEY
|
const SITE_SESSION_SECRET = process.env.SITE_SESSION_SECRET || process.env.API_SESSION_SECRET || TURNSTILE_SECRET_KEY
|
||||||
const DIST_DIR = path.join(__dirname, 'dist')
|
const DIST_DIR = path.join(__dirname, 'dist')
|
||||||
|
const SEO_IMAGE_PATH = '/embed.png'
|
||||||
|
const INDEX_ROBOTS = 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1'
|
||||||
|
const NOINDEX_ROBOTS = 'noindex, follow'
|
||||||
|
const BLOG_POSTS_DIR = path.join(__dirname, 'frontend', 'src', 'blog', 'posts')
|
||||||
const VEHICLE_ICONS_DIR = path.resolve(
|
const VEHICLE_ICONS_DIR = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
|
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
|
||||||
@@ -2369,6 +2373,80 @@ function decodeRouteSegment(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/['"]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlogFrontMatter(raw) {
|
||||||
|
const source = String(raw || '')
|
||||||
|
const match = source.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
|
||||||
|
if (!match) return { attributes: {}, content: source }
|
||||||
|
|
||||||
|
const attributes = {}
|
||||||
|
match[1].split('\n').forEach((line) => {
|
||||||
|
const separator = line.indexOf(':')
|
||||||
|
if (separator === -1) return
|
||||||
|
const key = line.slice(0, separator).trim()
|
||||||
|
const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')
|
||||||
|
if (key) attributes[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
return { attributes, content: match[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerptFromMarkdown(markdown) {
|
||||||
|
return String(markdown || '')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^#{1,6}\s+/, '').trim())
|
||||||
|
.find((line) => line && !line.startsWith('![') && !line.startsWith('>')) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogPosts() {
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(BLOG_POSTS_DIR, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
||||||
|
.map((entry) => {
|
||||||
|
const filePath = path.join(BLOG_POSTS_DIR, entry.name)
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const { attributes, content } = parseBlogFrontMatter(raw)
|
||||||
|
const filename = entry.name.replace(/\.md$/i, '')
|
||||||
|
const slug = slugify(attributes.slug || filename)
|
||||||
|
const date = attributes.date || ''
|
||||||
|
const timestamp = date ? Date.parse(date) : 0
|
||||||
|
return {
|
||||||
|
author: attributes.author || '',
|
||||||
|
date,
|
||||||
|
excerpt: attributes.excerpt || excerptFromMarkdown(content),
|
||||||
|
path: `/blog/${encodeURIComponent(slug)}`,
|
||||||
|
slug,
|
||||||
|
timestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
||||||
|
title: attributes.title || filename.replace(/[-_]+/g, ' ') || 'Untitled post',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((post) => post.slug && post.title)
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp || a.title.localeCompare(b.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogPostBySlug(slug) {
|
||||||
|
return blogPosts().find((post) => post.slug === slug) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGraphType(seo) {
|
||||||
|
return seo.type === 'BlogPosting' ? 'article' : 'website'
|
||||||
|
}
|
||||||
|
|
||||||
function routeSeo(pathname) {
|
function routeSeo(pathname) {
|
||||||
const cleanPath = pathname.replace(/\/+$/, '') || '/'
|
const cleanPath = pathname.replace(/\/+$/, '') || '/'
|
||||||
|
|
||||||
@@ -2376,10 +2454,11 @@ function routeSeo(pathname) {
|
|||||||
const teamName = decodeRouteSegment(cleanPath.slice('/teams/'.length))
|
const teamName = decodeRouteSegment(cleanPath.slice('/teams/'.length))
|
||||||
if (teamName) {
|
if (teamName) {
|
||||||
return {
|
return {
|
||||||
title: `${teamName} TSS Team Profile | Toothless' TSS Bot`,
|
title: `${teamName} War Thunder TSS Team Profile | Toothless' TSS Bot`,
|
||||||
description: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`,
|
description: `${teamName} War Thunder TSS team profile with roster details, battle history, and recent squadron activity.`,
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: `/teams/${encodeURIComponent(teamName)}`,
|
path: `/teams/${encodeURIComponent(teamName)}`,
|
||||||
|
type: 'ProfilePage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2389,67 +2468,140 @@ function routeSeo(pathname) {
|
|||||||
if (uid) {
|
if (uid) {
|
||||||
return {
|
return {
|
||||||
title: `Player ${uid} | Toothless' TSS Bot`,
|
title: `Player ${uid} | Toothless' TSS Bot`,
|
||||||
description: `TSS career stats for player ${uid} — battles, win rate, kills, and teams seen with.`,
|
description: `War Thunder TSS career stats for player ${uid}: battles, win rate, kills, and teams seen with.`,
|
||||||
robots: 'noindex, follow',
|
robots: NOINDEX_ROBOTS,
|
||||||
path: `/players/${encodeURIComponent(uid)}`,
|
path: `/players/${encodeURIComponent(uid)}`,
|
||||||
|
type: 'ProfilePage',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanPath.startsWith('/blog/')) {
|
||||||
|
const slug = slugify(decodeRouteSegment(cleanPath.slice('/blog/'.length)))
|
||||||
|
const post = blogPostBySlug(slug)
|
||||||
|
if (slug) {
|
||||||
|
return {
|
||||||
|
title: `${post?.title || 'Blog post'} | Toothless' TSS Bot`,
|
||||||
|
description: post?.excerpt || 'News, feature updates, and War Thunder TSS Bot announcements from Toothless TSS Bot.',
|
||||||
|
robots: post ? INDEX_ROBOTS : NOINDEX_ROBOTS,
|
||||||
|
path: `/blog/${encodeURIComponent(slug)}`,
|
||||||
|
type: 'BlogPosting',
|
||||||
|
publishedAt: post?.date || '',
|
||||||
|
author: post?.author || "Toothless' TSS Bot",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanPath.startsWith('/games/')) {
|
||||||
|
const gameId = decodeRouteSegment(cleanPath.slice('/games/'.length))
|
||||||
|
if (gameId) {
|
||||||
|
return {
|
||||||
|
title: `Game ${gameId} | Toothless' TSS Bot`,
|
||||||
|
description: `War Thunder TSS battle log details for game ${gameId}, including participants, map, player counts, and combat stats.`,
|
||||||
|
robots: NOINDEX_ROBOTS,
|
||||||
|
path: `/games/${encodeURIComponent(gameId)}`,
|
||||||
|
type: 'WebPage',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanPath.startsWith('/tournaments/')) {
|
||||||
|
const tournamentId = decodeRouteSegment(cleanPath.slice('/tournaments/'.length))
|
||||||
|
if (tournamentId) {
|
||||||
|
return {
|
||||||
|
title: `War Thunder TSS Tournament ${tournamentId} | Toothless' TSS Bot`,
|
||||||
|
description: `War Thunder TSS tournament ${tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
||||||
|
robots: INDEX_ROBOTS,
|
||||||
|
path: `/tournaments/${encodeURIComponent(tournamentId)}`,
|
||||||
|
type: 'CollectionPage',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const byPath = {
|
const byPath = {
|
||||||
'/': {
|
'/': {
|
||||||
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
|
title: "War Thunder TSS Leaderboards, Teams and Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.',
|
description: 'Track War Thunder Tournament Service teams, live TSS leaderboards, squadron profiles, battle logs, player stats, and tournament brackets.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
type: 'WebApplication',
|
||||||
},
|
},
|
||||||
'/teams': {
|
'/teams': {
|
||||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
title: "War Thunder TSS Team Leaderboard | Toothless' TSS Bot",
|
||||||
description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.',
|
description: 'Browse the live War Thunder TSS team leaderboard with squadron rankings, points, players, win rates, battles, and recent activity.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/teams',
|
path: '/teams',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
'/players': {
|
'/players': {
|
||||||
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
title: "War Thunder TSS Player Leaderboard | Toothless' TSS Bot",
|
||||||
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
description: 'Browse the War Thunder TSS player leaderboard and compare player score, kills, assists, win rate, KDR, teams, and battle activity.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/players',
|
path: '/players',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
'/battle-logs': {
|
'/battle-logs': {
|
||||||
title: "TSS Battle Logs | Toothless' TSS Bot",
|
title: "War Thunder TSS Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
description: 'Read recent War Thunder TSS battle logs with teams, maps, player counts, battle times, replays, and match context.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/battle-logs',
|
path: '/battle-logs',
|
||||||
|
type: 'CollectionPage',
|
||||||
},
|
},
|
||||||
'/live': {
|
'/live': {
|
||||||
title: "TSS Battle Logs | Toothless' TSS Bot",
|
title: "War Thunder TSS Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
description: 'Read recent War Thunder TSS battle logs with teams, maps, player counts, battle times, replays, and match context.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/battle-logs',
|
path: '/battle-logs',
|
||||||
|
type: 'CollectionPage',
|
||||||
|
},
|
||||||
|
'/tournaments': {
|
||||||
|
title: 'War Thunder TSS Tournaments | Brackets and Standings',
|
||||||
|
description: "Browse War Thunder TSS tournaments with brackets, standings, teams, matches, and replay links tracked by Toothless' TSS Bot.",
|
||||||
|
robots: INDEX_ROBOTS,
|
||||||
|
path: '/tournaments',
|
||||||
|
type: 'CollectionPage',
|
||||||
|
},
|
||||||
|
'/blog': {
|
||||||
|
title: "War Thunder TSS Bot Blog | Toothless' TSS Bot",
|
||||||
|
description: 'News, feature updates, tournament tracking notes, and War Thunder TSS Bot announcements from Toothless TSS Bot.',
|
||||||
|
robots: INDEX_ROBOTS,
|
||||||
|
path: '/blog',
|
||||||
|
type: 'Blog',
|
||||||
},
|
},
|
||||||
'/uptime': {
|
'/uptime': {
|
||||||
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
||||||
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/uptime',
|
path: '/uptime',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
'/viewers': {
|
'/viewers': {
|
||||||
title: "Viewer Analytics | Toothless' TSS Bot",
|
title: "Viewer Analytics | Toothless' TSS Bot",
|
||||||
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
|
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
|
||||||
robots: 'noindex, follow',
|
robots: NOINDEX_ROBOTS,
|
||||||
path: '/viewers',
|
path: '/viewers',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
'/privacy': {
|
'/privacy': {
|
||||||
title: "Privacy Notice | Toothless' TSS Bot",
|
title: "Privacy Notice | Toothless' TSS Bot",
|
||||||
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
|
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
|
||||||
robots: 'index, follow',
|
robots: INDEX_ROBOTS,
|
||||||
path: '/privacy',
|
path: '/privacy',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
'/docs': {
|
'/docs': {
|
||||||
title: "Docs | Toothless' TSS Bot",
|
title: "Docs | Toothless' TSS Bot",
|
||||||
description: 'Documentation and command reference for Toothless TSS Bot.',
|
description: 'Documentation and command reference for Toothless TSS Bot.',
|
||||||
robots: 'noindex, follow',
|
robots: NOINDEX_ROBOTS,
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
|
type: 'WebPage',
|
||||||
|
},
|
||||||
|
'/settings': {
|
||||||
|
title: "Settings | Toothless' TSS Bot",
|
||||||
|
description: 'Customize layout and appearance preferences for Toothless TSS Bot, including custom theme accent colors.',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
path: '/settings',
|
||||||
|
type: 'WebPage',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2457,13 +2609,18 @@ function routeSeo(pathname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function routeStructuredData(origin, seo, canonicalUrl) {
|
function routeStructuredData(origin, seo, canonicalUrl) {
|
||||||
return JSON.stringify([
|
const siteId = `${origin}/#website`
|
||||||
|
const pageId = `${canonicalUrl}#webpage`
|
||||||
|
const imageUrl = `${origin}${SEO_IMAGE_PATH}`
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
|
'@id': siteId,
|
||||||
name: "Toothless' TSS Bot",
|
name: "Toothless' TSS Bot",
|
||||||
|
alternateName: ['TSS Bot', 'War Thunder TSS Bot', 'Toothless TSS Bot'],
|
||||||
url: `${origin}/`,
|
url: `${origin}/`,
|
||||||
description: 'Live War Thunder TSS leaderboards, battle logs, and team profiles.',
|
description: 'Live War Thunder Tournament Service leaderboards, battle logs, tournaments, and team profiles.',
|
||||||
potentialAction: {
|
potentialAction: {
|
||||||
'@type': 'SearchAction',
|
'@type': 'SearchAction',
|
||||||
target: `${origin}/teams/{search_term_string}`,
|
target: `${origin}/teams/{search_term_string}`,
|
||||||
@@ -2472,17 +2629,65 @@ function routeStructuredData(origin, seo, canonicalUrl) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': seo.type || 'WebPage',
|
||||||
|
'@id': pageId,
|
||||||
name: seo.title,
|
name: seo.title,
|
||||||
url: canonicalUrl,
|
url: canonicalUrl,
|
||||||
description: seo.description,
|
description: seo.description,
|
||||||
|
image: imageUrl,
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@type': 'WebSite',
|
'@id': siteId,
|
||||||
name: "Toothless' TSS Bot",
|
|
||||||
url: `${origin}/`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: 'Home',
|
||||||
|
item: `${origin}/`,
|
||||||
|
},
|
||||||
|
...(seo.path === '/' ? [] : [{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||||
|
item: canonicalUrl,
|
||||||
|
}]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (seo.type === 'BlogPosting') {
|
||||||
|
items.push({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||||
|
description: seo.description,
|
||||||
|
url: canonicalUrl,
|
||||||
|
image: imageUrl,
|
||||||
|
datePublished: seo.publishedAt || undefined,
|
||||||
|
dateModified: seo.publishedAt || undefined,
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: seo.author || "Toothless' TSS Bot",
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: "Toothless' TSS Bot",
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: imageUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@id': pageId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlWithSeo(req, data) {
|
function htmlWithSeo(req, data) {
|
||||||
@@ -2504,6 +2709,11 @@ function htmlWithSeo(req, data) {
|
|||||||
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description))
|
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description))
|
||||||
.replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots))
|
.replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots))
|
||||||
.replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl))
|
.replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl))
|
||||||
|
.replaceAll('__SEO_OG_TYPE__', escapeHtml(openGraphType(seo)))
|
||||||
|
.replaceAll('__SEO_IMAGE__', escapeHtml(`${origin}${SEO_IMAGE_PATH}`))
|
||||||
|
.replaceAll('__SEO_AUTHOR__', escapeHtml(seo.author || "Toothless' TSS Bot"))
|
||||||
|
.replaceAll('__SEO_PUBLISHED_TIME__', escapeHtml(seo.publishedAt || ''))
|
||||||
|
.replaceAll('__SEO_MODIFIED_TIME__', escapeHtml(seo.publishedAt || ''))
|
||||||
.replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/</g, '\\u003c'))
|
.replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/</g, '\\u003c'))
|
||||||
.replaceAll('__TURNSTILE_SESSION__', isTurnstileSessionVerified(req) ? 'verified' : 'required')
|
.replaceAll('__TURNSTILE_SESSION__', isTurnstileSessionVerified(req) ? 'verified' : 'required')
|
||||||
}
|
}
|
||||||
@@ -2523,7 +2733,9 @@ function sendRobotsTxt(req, res) {
|
|||||||
'User-agent: *',
|
'User-agent: *',
|
||||||
'Allow: /',
|
'Allow: /',
|
||||||
'Disallow: /api/',
|
'Disallow: /api/',
|
||||||
|
'Disallow: /data/',
|
||||||
'Disallow: /viewers',
|
'Disallow: /viewers',
|
||||||
|
'Disallow: /settings',
|
||||||
`Sitemap: ${origin}/sitemap.xml`,
|
`Sitemap: ${origin}/sitemap.xml`,
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -2540,15 +2752,24 @@ function sendSitemapXml(req, res) {
|
|||||||
const urls = [
|
const urls = [
|
||||||
{ path: '/', priority: '1.0', changefreq: 'hourly' },
|
{ path: '/', priority: '1.0', changefreq: 'hourly' },
|
||||||
{ path: '/teams', priority: '0.9', changefreq: 'hourly' },
|
{ path: '/teams', priority: '0.9', changefreq: 'hourly' },
|
||||||
|
{ path: '/players', priority: '0.85', changefreq: 'hourly' },
|
||||||
{ path: '/battle-logs', priority: '0.9', changefreq: 'hourly' },
|
{ path: '/battle-logs', priority: '0.9', changefreq: 'hourly' },
|
||||||
|
{ path: '/tournaments', priority: '0.85', changefreq: 'daily' },
|
||||||
|
{ path: '/blog', priority: '0.7', changefreq: 'weekly' },
|
||||||
{ path: '/uptime', priority: '0.5', changefreq: 'daily' },
|
{ path: '/uptime', priority: '0.5', changefreq: 'daily' },
|
||||||
{ path: '/privacy', priority: '0.3', changefreq: 'monthly' },
|
{ path: '/privacy', priority: '0.3', changefreq: 'monthly' },
|
||||||
|
...blogPosts().map((post) => ({
|
||||||
|
path: post.path,
|
||||||
|
priority: '0.6',
|
||||||
|
changefreq: 'monthly',
|
||||||
|
lastmod: post.date || today,
|
||||||
|
})),
|
||||||
]
|
]
|
||||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
${urls.map((url) => ` <url>
|
${urls.map((url) => ` <url>
|
||||||
<loc>${escapeHtml(`${origin}${url.path}`)}</loc>
|
<loc>${escapeHtml(`${origin}${url.path}`)}</loc>
|
||||||
<lastmod>${today}</lastmod>
|
<lastmod>${escapeHtml(url.lastmod || today)}</lastmod>
|
||||||
<changefreq>${url.changefreq}</changefreq>
|
<changefreq>${url.changefreq}</changefreq>
|
||||||
<priority>${url.priority}</priority>
|
<priority>${url.priority}</priority>
|
||||||
</url>`).join('\n')}
|
</url>`).join('\n')}
|
||||||
|
|||||||
Reference in New Issue
Block a user