9.0 KiB
9.0 KiB
SEO & Structured Data
StructuredData.astro
Generic JSON-LD renderer — copy verbatim:
---
interface Props {
data: Record<string, unknown>;
}
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />
BaseHead.astro
---
import '../styles/global.css';
import type { ImageMetadata } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
robots?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image, type = 'website', publishedTime, modifiedTime, robots } = Astro.props;
const ogImageUrl = image
? new URL(image.src, Astro.url).toString()
: new URL('/og-default.jpg', Astro.site).toString();
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="{{GOOGLE_FONTS_URL}}" rel="stylesheet" />
<link rel="preload" href="/assets/fonts/{{FONT_FILE}}.woff2" as="font" type="font/woff2" crossorigin />
<link rel="canonical" href={canonicalURL} />
<link rel="alternate" type="application/rss+xml" title="{{COMPANY}} Blog" href="/rss.xml" />
{robots && <meta name="robots" content={robots} />}
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:site_name" content="{{COMPANY}}" />
<meta property="og:type" content={type} />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="{{TWITTER_HANDLE}}" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
Organization Schema (every page, via BaseLayout)
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
logo: '{{SITE_URL}}/favicon-192.png',
sameAs: [
// Include only applicable social links
SOCIAL_LINKS.steam,
SOCIAL_LINKS.twitter,
SOCIAL_LINKS.discord,
],
}
Article Schema (blog posts, via BlogPost layout)
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
datePublished: pubDate.toISOString(),
...(updatedDate && { dateModified: updatedDate.toISOString() }),
...(heroImage && { image: new URL(heroImage.src, Astro.url).toString() }),
author: {
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
},
publisher: {
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
logo: { '@type': 'ImageObject', url: '{{SITE_URL}}/favicon-192.png' },
},
};
BreadcrumbList Schema (blog posts)
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: '{{SITE_URL}}/' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: '{{SITE_URL}}/blog/' },
{ '@type': 'ListItem', position: 3, name: title },
],
};
VideoGame Schema (for game product pages)
{
'@context': 'https://schema.org',
'@type': 'VideoGame',
name: '{{GAME_NAME}}',
description: '{{GAME_DESCRIPTION}}',
url: '{{GAME_URL}}',
gamePlatform: ['PC'],
applicationCategory: 'Game',
operatingSystem: 'Windows',
author: {
'@type': 'Organization',
name: '{{COMPANY}}',
},
}
Product Schema (for product pages)
{
'@context': 'https://schema.org',
'@type': 'Product',
name: '{{PRODUCT_NAME}}',
description: '{{PRODUCT_DESCRIPTION}}',
brand: { '@type': 'Brand', name: '{{COMPANY}}' },
offers: {
'@type': 'Offer',
price: '{{PRICE}}',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
}
LocalBusiness Schema (for service businesses)
{
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
email: '{{CONTACT_EMAIL}}',
address: {
'@type': 'PostalAddress',
addressLocality: '{{CITY}}',
addressRegion: '{{STATE}}',
addressCountry: 'US',
},
}
robots.txt
User-agent: *
Allow: /
LLM-Policy: /llms.txt
Sitemap: {{SITE_URL}}/sitemap-index.xml
RSS Feed (src/pages/rss.xml.ts)
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
export const prerender = true;
export async function GET(context: APIContext) {
const posts = await getCollection('blog');
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site!,
items: posts.map((post) => ({
...post.data,
link: `/blog/${post.id}/`,
})),
});
}
LLM Context: llms.txt (src/pages/llms.txt.ts)
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? '{{SITE_URL}}';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'## Blog Posts',
'',
];
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
lines.push(`- [${post.data.title}](${url}) - ${date}`);
}
lines.push('', '## Additional Resources', '',
`- [RSS Feed](${site}/rss.xml)`,
`- [Sitemap](${site}/sitemap-index.xml)`,
`- [Full LLM Context](${site}/llms-full.txt)`,
'');
return new Response(lines.join('\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
LLM Full Context: llms-full.txt (src/pages/llms-full.txt.ts)
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? '{{SITE_URL}}';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## About This File',
'',
'Full content of all blog posts, formatted for LLM consumption.',
'For a shorter index, see /llms.txt',
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'---',
'',
'## Blog Posts',
'',
];
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
const category = post.data.category ?? 'Uncategorized';
const tags = post.data.tags?.join(', ') ?? '';
lines.push(`### ${post.data.title}`, '',
`- **URL**: ${url}`,
`- **Date**: ${date}`,
`- **Category**: ${category}`);
if (tags) lines.push(`- **Tags**: ${tags}`);
lines.push(`- **Description**: ${post.data.description}`, '',
'#### Content', '',
post.body || '*No content body available*',
'', '---', '');
}
lines.push('## Additional Resources', '',
`- [RSS Feed](${site}/rss.xml)`,
`- [Sitemap](${site}/sitemap-index.xml)`, '');
return new Response(lines.join('\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
URL Pattern
All blog URLs: /blog/${post.id}/ with trailing slash. Consistent across blog listing, homepage featured posts, search index, RSS feed, and LLM context files.