Skip to content

Commit b5e8ff8

Browse files
authored
Merge pull request #3 from QVerisAI/feat/seo-aeo-optimization
Add SEO/AEO optimizations for blog listing and detail pages
2 parents 80ee3b2 + a5308d8 commit b5e8ff8

9 files changed

Lines changed: 427 additions & 34 deletions

File tree

src/components/BaseHead.astro

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,57 @@
22
import '../styles/global.css';
33
import type { ImageMetadata } from 'astro';
44
import FallbackImage from '../assets/qveris-brand.png';
5-
import { SITE_TITLE } from '../consts';
5+
import { SITE_TITLE, SITE_URL } from '../consts';
6+
import { locales, defaultLocale } from '../i18n/config';
7+
import type { Locale } from '../i18n/config';
8+
import { switchLocaleInPath, getHtmlLang } from '../i18n/utils';
69
710
interface Props {
811
title: string;
912
description: string;
1013
image?: ImageMetadata;
14+
// SEO enhancements
15+
lang?: Locale;
16+
ogType?: 'website' | 'article';
17+
pubDate?: Date;
18+
updatedDate?: Date;
19+
author?: string;
20+
tags?: string[];
1121
}
1222
13-
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
23+
const {
24+
title,
25+
description,
26+
image = FallbackImage,
27+
lang = 'en',
28+
ogType = 'website',
29+
pubDate,
30+
updatedDate,
31+
author,
32+
tags,
33+
} = Astro.props;
1434
15-
const { title, description, image = FallbackImage } = Astro.props;
35+
// Use Astro.site with SITE_URL fallback for all URL construction
36+
const site = Astro.site ?? new URL(SITE_URL);
37+
const canonicalURL = new URL(Astro.url.pathname, site);
38+
const ogLocale = lang === 'cn' ? 'zh_CN' : 'en_US';
39+
const imageUrl = new URL(image.src, Astro.url);
40+
41+
// hreflang: dynamically generate alternate links for all supported locales
42+
const pathname = Astro.url.pathname;
43+
const hreflangLinks = locales.map((loc) => ({
44+
hreflang: getHtmlLang(loc),
45+
href: new URL(switchLocaleInPath(pathname, loc), site),
46+
}));
47+
const defaultUrl = new URL(switchLocaleInPath(pathname, defaultLocale), site);
1648
---
1749

1850
<meta charset="utf-8" />
1951
<meta name="viewport" content="width=device-width,initial-scale=1" />
2052
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
2153
<link rel="icon" href="/favicon.ico" />
2254
<link rel="sitemap" href="/sitemap-index.xml" />
23-
<link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href={new URL('rss.xml', Astro.site)} />
55+
<link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href={new URL('rss.xml', site)} />
2456
<meta name="generator" content={Astro.generator} />
2557

2658
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -32,20 +64,46 @@ const { title, description, image = FallbackImage } = Astro.props;
3264

3365
<meta name="theme-color" content="#05070B" />
3466

67+
<!-- Canonical & hreflang -->
3568
<link rel="canonical" href={canonicalURL} />
69+
{hreflangLinks.map(({ hreflang, href }) => (
70+
<link rel="alternate" hreflang={hreflang} href={href} />
71+
))}
72+
<link rel="alternate" hreflang="x-default" href={defaultUrl} />
3673

74+
<!-- Primary meta -->
3775
<title>{title}</title>
38-
<meta name="title" content={title} />
3976
<meta name="description" content={description} />
77+
{author && <meta name="author" content={author} />}
4078

41-
<meta property="og:type" content="website" />
79+
<!-- Open Graph -->
80+
<meta property="og:type" content={ogType} />
4281
<meta property="og:url" content={Astro.url} />
4382
<meta property="og:title" content={title} />
4483
<meta property="og:description" content={description} />
45-
<meta property="og:image" content={new URL(image.src, Astro.url)} />
84+
<meta property="og:image" content={imageUrl} />
85+
<meta property="og:image:width" content={String(image.width)} />
86+
<meta property="og:image:height" content={String(image.height)} />
87+
<meta property="og:image:alt" content={title} />
88+
<meta property="og:locale" content={ogLocale} />
89+
<meta property="og:site_name" content={SITE_TITLE} />
90+
91+
{ogType === 'article' && pubDate && (
92+
<meta property="article:published_time" content={pubDate.toISOString()} />
93+
)}
94+
{ogType === 'article' && updatedDate && (
95+
<meta property="article:modified_time" content={updatedDate.toISOString()} />
96+
)}
97+
{ogType === 'article' && author && (
98+
<meta property="article:author" content={author} />
99+
)}
100+
{ogType === 'article' && tags?.map((tag) => (
101+
<meta property="article:tag" content={tag} />
102+
))}
46103

47-
<meta property="twitter:card" content="summary_large_image" />
48-
<meta property="twitter:url" content={Astro.url} />
49-
<meta property="twitter:title" content={title} />
50-
<meta property="twitter:description" content={description} />
51-
<meta property="twitter:image" content={new URL(image.src, Astro.url)} />
104+
<!-- Twitter Card -->
105+
<meta name="twitter:card" content="summary_large_image" />
106+
<meta name="twitter:url" content={Astro.url} />
107+
<meta name="twitter:title" content={title} />
108+
<meta name="twitter:description" content={description} />
109+
<meta name="twitter:image" content={imageUrl} />

src/components/JsonLd.astro

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
---
2+
/**
3+
* JSON-LD structured data for SEO and AEO.
4+
*
5+
* Usage:
6+
* <JsonLd type="BlogPosting" data={{ title, description, ... }} />
7+
* <JsonLd type="BreadcrumbList" data={{ items: [...] }} />
8+
* <JsonLd type="CollectionPage" data={{ title, description, posts: [...] }} />
9+
*/
10+
11+
interface BreadcrumbItem {
12+
name: string;
13+
url?: string;
14+
}
15+
16+
interface PostRef {
17+
title: string;
18+
url: string;
19+
}
20+
21+
interface BlogPostingData {
22+
title: string;
23+
description: string;
24+
url: string;
25+
image?: string;
26+
pubDate?: Date;
27+
updatedDate?: Date;
28+
author?: string;
29+
tags?: string[];
30+
category?: string;
31+
lang: string;
32+
wordCount?: number;
33+
}
34+
35+
interface BreadcrumbData {
36+
items: BreadcrumbItem[];
37+
}
38+
39+
interface CollectionPageData {
40+
title: string;
41+
description: string;
42+
url: string;
43+
posts: PostRef[];
44+
}
45+
46+
type Props =
47+
| { type: 'BlogPosting'; data: BlogPostingData }
48+
| { type: 'TechArticle'; data: BlogPostingData }
49+
| { type: 'BreadcrumbList'; data: BreadcrumbData }
50+
| { type: 'CollectionPage'; data: CollectionPageData };
51+
52+
import { SITE_URL } from '../consts';
53+
54+
const { type, data } = Astro.props;
55+
const site = Astro.site?.toString().replace(/\/$/, '') ?? SITE_URL;
56+
57+
function buildArticle(d: BlogPostingData, schemaType: string = 'BlogPosting') {
58+
return {
59+
'@context': 'https://schema.org',
60+
'@type': schemaType,
61+
headline: d.title,
62+
description: d.description,
63+
url: d.url,
64+
...(d.image && { image: d.image }),
65+
...(d.pubDate && { datePublished: d.pubDate.toISOString() }),
66+
...(d.updatedDate && { dateModified: d.updatedDate.toISOString() }),
67+
...(d.wordCount && { wordCount: d.wordCount }),
68+
...(d.category && { articleSection: d.category }),
69+
...(d.tags?.length && { keywords: d.tags.join(', ') }),
70+
inLanguage: d.lang === 'cn' ? 'zh-CN' : 'en',
71+
author: d.author
72+
? { '@type': 'Person', name: d.author }
73+
: { '@type': 'Organization', name: 'QVeris', url: site },
74+
publisher: {
75+
'@type': 'Organization',
76+
name: 'QVeris',
77+
url: site,
78+
logo: {
79+
'@type': 'ImageObject',
80+
url: `${site}/favicon.svg`,
81+
},
82+
},
83+
mainEntityOfPage: {
84+
'@type': 'WebPage',
85+
'@id': d.url,
86+
},
87+
};
88+
}
89+
90+
function buildBreadcrumbList(d: BreadcrumbData) {
91+
return {
92+
'@context': 'https://schema.org',
93+
'@type': 'BreadcrumbList',
94+
itemListElement: d.items.map((item, i) => ({
95+
'@type': 'ListItem',
96+
position: i + 1,
97+
name: item.name,
98+
...(item.url && { item: item.url }),
99+
})),
100+
};
101+
}
102+
103+
function buildCollectionPage(d: CollectionPageData) {
104+
return {
105+
'@context': 'https://schema.org',
106+
'@type': 'CollectionPage',
107+
name: d.title,
108+
description: d.description,
109+
url: d.url,
110+
mainEntity: {
111+
'@type': 'ItemList',
112+
numberOfItems: d.posts.length,
113+
itemListElement: d.posts.map((post, i) => ({
114+
'@type': 'ListItem',
115+
position: i + 1,
116+
name: post.title,
117+
url: post.url,
118+
})),
119+
},
120+
publisher: {
121+
'@type': 'Organization',
122+
name: 'QVeris',
123+
url: site,
124+
},
125+
};
126+
}
127+
128+
let jsonLd: Record<string, unknown>;
129+
130+
switch (type) {
131+
case 'BlogPosting':
132+
jsonLd = buildArticle(data as BlogPostingData, 'BlogPosting');
133+
break;
134+
case 'TechArticle':
135+
jsonLd = buildArticle(data as BlogPostingData, 'TechArticle');
136+
break;
137+
case 'BreadcrumbList':
138+
jsonLd = buildBreadcrumbList(data as BreadcrumbData);
139+
break;
140+
case 'CollectionPage':
141+
jsonLd = buildCollectionPage(data as CollectionPageData);
142+
break;
143+
}
144+
---
145+
146+
<script type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, '\\u003c')} />

src/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const SITE_TITLE = 'QVeris';
22
export const SITE_DESCRIPTION = 'Engineering insights on AI agents, infrastructure, and the future of agentic computing.';
3+
export const SITE_URL = 'https://qveris.ai';

src/layouts/BaseLayout.astro

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import BaseHead from '../components/BaseHead.astro';
33
import Nav from '../components/Nav.astro';
4+
import type { ImageMetadata } from 'astro';
45
import type { Locale, Region } from '../i18n/config';
56
import { getHtmlLang } from '../i18n/utils';
67
@@ -11,16 +12,47 @@ interface Props {
1112
section: 'blog' | 'doc';
1213
region?: Region;
1314
embed?: boolean;
15+
// SEO pass-through
16+
ogType?: 'website' | 'article';
17+
image?: ImageMetadata;
18+
pubDate?: Date;
19+
updatedDate?: Date;
20+
author?: string;
21+
tags?: string[];
1422
}
1523
16-
const { title, description, lang, section, region = 'global', embed = false } = Astro.props;
24+
const {
25+
title,
26+
description,
27+
lang,
28+
section,
29+
region = 'global',
30+
embed = false,
31+
ogType = 'website',
32+
image,
33+
pubDate,
34+
updatedDate,
35+
author,
36+
tags,
37+
} = Astro.props;
1738
const htmlLang = getHtmlLang(lang);
1839
---
1940

2041
<!doctype html>
2142
<html lang={htmlLang}>
2243
<head>
23-
<BaseHead title={title} description={description} />
44+
<BaseHead
45+
title={title}
46+
description={description}
47+
lang={lang}
48+
ogType={ogType}
49+
image={image}
50+
pubDate={pubDate}
51+
updatedDate={updatedDate}
52+
author={author}
53+
tags={tags}
54+
/>
55+
<slot name="head" />
2456
</head>
2557
<body class="embed-body">
2658
<Nav lang={lang} section={section} region={region} embed={embed} />

0 commit comments

Comments
 (0)