Skip to content

Commit 2656325

Browse files
committed
feat(seo): improve search engine discovery and metadata indexing
introduces a dynamic sitemap and updated robots.txt to better manage crawler access. adds json-ld structured data for movies, shows, and the homepage to enhance search results with rich snippets, and enables public caching for bots to optimize indexing performance.
1 parent cecc33b commit 2656325

6 files changed

Lines changed: 162 additions & 17 deletions

File tree

commitlint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ module.exports = {
103103
'share',
104104
'search',
105105
'sentiment',
106+
'seo',
106107
'settings',
107108
'show',
108109
'smart-list',

projects/client/src/app.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@
5151
href="%sveltekit.assets%/pwa/ios/apple-touch-icon.png"
5252
/>
5353

54+
<!-- SEO defaults (overridden per-page by TraktPage) -->
55+
<meta
56+
name="description"
57+
content="Trakt Web: Track Your Shows &amp; Movies"
58+
/>
59+
<meta name="theme-color" content="#ed1c24" />
60+
5461
%sveltekit.head%
5562
</head>
5663
<body

projects/client/src/hooks.server.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,22 @@ const WHITELISTED_HEADERS = new Set([
2828
export const handleCacheControl: Handle = async ({ event, resolve }) => {
2929
const response = await resolve(event);
3030

31-
if (response.headers.get('content-type')?.includes('text/html')) {
32-
const clonedHeaders = new Headers(response.headers);
31+
if (!response.headers.get('content-type')?.includes('text/html')) {
32+
return response;
33+
}
3334

34-
clonedHeaders.set(
35-
'Cache-Control',
36-
'private, no-store, no-cache, must-revalidate',
37-
);
35+
const clonedHeaders = new Headers(response.headers);
36+
const cacheControl = event.locals.isLegitimateBot
37+
? 'public, max-age=3600, s-maxage=3600'
38+
: 'private, no-store, no-cache, must-revalidate';
3839

39-
return new Response(response.body, {
40-
status: response.status,
41-
statusText: response.statusText,
42-
headers: clonedHeaders,
43-
});
44-
}
40+
clonedHeaders.set('Cache-Control', cacheControl);
4541

46-
return response;
42+
return new Response(response.body, {
43+
status: response.status,
44+
statusText: response.statusText,
45+
headers: clonedHeaders,
46+
});
4747
};
4848

4949
export const handle: Handle = sequence(

projects/client/src/lib/sections/layout/TraktPage.svelte

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { page } from "$app/state";
3+
import { getLanguageAndRegion } from "$lib/features/i18n";
34
import { DpadNavigationType } from "$lib/features/navigation/models/DpadNavigationType";
45
import RenderFor from "$lib/guards/RenderFor.svelte";
56
import type { MediaType } from "$lib/requests/models/MediaType";
@@ -73,9 +74,53 @@
7374
: _info,
7475
);
7576
76-
const canonicalUrl = $derived(
77-
`${page.url.origin}${page.url.pathname}`,
78-
);
77+
const canonicalUrl = $derived(`${page.url.origin}${page.url.pathname}`);
78+
79+
const ogLocale = $derived.by(() => {
80+
const { language, region } = getLanguageAndRegion();
81+
return `${language}_${region.toUpperCase()}`;
82+
});
83+
84+
const isIndexable = $derived(audience === "all" || audience === "public");
85+
86+
const createWebsiteLd = (url: string) =>
87+
JSON.stringify({
88+
"@context": "https://schema.org",
89+
"@type": "WebSite",
90+
name: websiteName,
91+
url,
92+
description: websiteTitle,
93+
});
94+
95+
const createMovieLd = (title: string, url: string) => {
96+
const duration = _info?.runtime;
97+
return JSON.stringify({
98+
"@context": "https://schema.org",
99+
"@type": "Movie",
100+
name: title,
101+
description: _info?.overview,
102+
image: _image,
103+
url,
104+
...(duration && duration > 0 ? { duration: `PT${duration}M` } : {}),
105+
});
106+
};
107+
108+
const createShowLd = (title: string, url: string) =>
109+
JSON.stringify({
110+
"@context": "https://schema.org",
111+
"@type": "TVSeries",
112+
name: title,
113+
description: _info?.overview,
114+
image: _image,
115+
url,
116+
});
117+
118+
const jsonLd = $derived.by(() => {
119+
if (type === "home") return createWebsiteLd(canonicalUrl);
120+
if (type === "movie" && _title) return createMovieLd(_title, canonicalUrl);
121+
if (type === "show" && _title) return createShowLd(_title, canonicalUrl);
122+
return null;
123+
});
79124
80125
const dynamicContentProps = $derived(
81126
hasDynamicContent
@@ -89,12 +134,17 @@
89134
<svelte:head>
90135
<title>{displayTitle}</title>
91136
<link rel="canonical" href={canonicalUrl} />
137+
<meta
138+
name="robots"
139+
content={isIndexable ? "index, follow" : "noindex, nofollow"}
140+
/>
92141
<meta property="og:site_name" content={websiteName} />
93142
<meta property="og:type" content={ogType} />
94143
<meta property="og:url" content={canonicalUrl} />
95144
<meta property="og:image" content={image} />
145+
<meta property="og:image:alt" content={ogTitle} />
96146
<meta property="og:title" content={ogTitle} />
97-
<meta property="og:locale" content="en_US" />
147+
<meta property="og:locale" content={ogLocale} />
98148
<meta property="og:updated_time" content={new Date().toISOString()} />
99149

100150
{#if info != null}
@@ -111,6 +161,10 @@
111161
<meta name="twitter:title" content={ogTitle} />
112162
<meta name="twitter:image" content={image} />
113163
<meta name="twitter:creator" content={twitterHandle} />
164+
165+
{#if jsonLd != null}
166+
{@html `<script type="application/ld+json">${jsonLd}</script>`}
167+
{/if}
114168
</svelte:head>
115169

116170
<RenderFor {audience}>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { RequestHandler } from '@sveltejs/kit';
2+
3+
const ORIGIN = 'https://app.trakt.tv';
4+
5+
type SitemapEntry = {
6+
path: string;
7+
priority: string;
8+
changefreq:
9+
| 'always'
10+
| 'hourly'
11+
| 'daily'
12+
| 'weekly'
13+
| 'monthly'
14+
| 'yearly'
15+
| 'never';
16+
};
17+
18+
const STATIC_ROUTES: ReadonlyArray<SitemapEntry> = [
19+
{ path: '/', priority: '1.0', changefreq: 'daily' },
20+
{ path: '/movies', priority: '0.9', changefreq: 'daily' },
21+
{ path: '/movies/popular', priority: '0.8', changefreq: 'daily' },
22+
{ path: '/movies/trending', priority: '0.8', changefreq: 'hourly' },
23+
{ path: '/movies/anticipated', priority: '0.7', changefreq: 'weekly' },
24+
{ path: '/movies/recommended', priority: '0.7', changefreq: 'daily' },
25+
{ path: '/shows', priority: '0.9', changefreq: 'daily' },
26+
{ path: '/shows/popular', priority: '0.8', changefreq: 'daily' },
27+
{ path: '/shows/trending', priority: '0.8', changefreq: 'hourly' },
28+
{ path: '/shows/anticipated', priority: '0.7', changefreq: 'weekly' },
29+
{ path: '/shows/recommended', priority: '0.7', changefreq: 'daily' },
30+
{ path: '/media/popular', priority: '0.7', changefreq: 'daily' },
31+
{ path: '/media/trending', priority: '0.7', changefreq: 'hourly' },
32+
{ path: '/media/anticipated', priority: '0.7', changefreq: 'weekly' },
33+
{ path: '/media/recommended', priority: '0.7', changefreq: 'daily' },
34+
{ path: '/discover', priority: '0.8', changefreq: 'daily' },
35+
{ path: '/search', priority: '0.6', changefreq: 'monthly' },
36+
{ path: '/vip', priority: '0.5', changefreq: 'monthly' },
37+
{ path: '/about', priority: '0.4', changefreq: 'monthly' },
38+
{ path: '/privacy', priority: '0.3', changefreq: 'monthly' },
39+
{ path: '/terms', priority: '0.3', changefreq: 'monthly' },
40+
];
41+
42+
const buildUrl = (
43+
{ path, priority, changefreq }: SitemapEntry,
44+
lastmod: string,
45+
) =>
46+
` <url>
47+
<loc>${ORIGIN}${path}</loc>
48+
<lastmod>${lastmod}</lastmod>
49+
<changefreq>${changefreq}</changefreq>
50+
<priority>${priority}</priority>
51+
</url>`;
52+
53+
const buildSitemap = (lastmod: string) => {
54+
const urls = STATIC_ROUTES.map((entry) => buildUrl(entry, lastmod)).join(
55+
'\n',
56+
);
57+
58+
return `<?xml version="1.0" encoding="UTF-8"?>
59+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
60+
${urls}
61+
</urlset>`;
62+
};
63+
64+
export const GET: RequestHandler = () => {
65+
const lastmod = new Date().toISOString().split('T')[0]!;
66+
67+
return new Response(buildSitemap(lastmod), {
68+
headers: {
69+
'Content-Type': 'application/xml; charset=utf-8',
70+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
71+
},
72+
});
73+
};

projects/client/static/robots.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,12 @@
11
User-agent: *
22
Allow: /
3+
Disallow: /calendar
4+
Disallow: /history
5+
Disallow: /settings
6+
Disallow: /social
7+
Disallow: /callback
8+
Disallow: /silent-redirect
9+
Disallow: /api/
10+
Disallow: /_design_system
11+
12+
Sitemap: https://app.trakt.tv/sitemap.xml

0 commit comments

Comments
 (0)