feat: Add SEO/AEO/Geo improvements

- Add ai.txt and llms.txt endpoints for AI/LLM discoverability
- Enhance metadata across all pages (canonical URLs, OpenGraph, Twitter)
- Add structured data (JSON-LD) to blog index, tag pages
- Update robots.txt with AI crawler rules
- Improve BlogPosting and CollectionPage schemas
This commit is contained in:
2026-03-14 12:19:18 +08:00
parent 08117a11c5
commit efb57b691b
11 changed files with 430 additions and 25 deletions

View File

@@ -43,12 +43,21 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
title: post.title,
description: post.description || post.title,
authors: post.authors?.length ? post.authors.map(author => ({ name: author })) : [{ name: siteConfig.author }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
title: post.title,
description: post.description || post.title,
type: 'article',
publishedTime: post.published_at,
authors: post.authors,
authors: post.authors?.length ? post.authors : [siteConfig.author],
tags: post.tags,
images: [
{
@@ -97,6 +106,11 @@ export default async function BlogPostPage({ params }: Props) {
? `${siteConfig.url}${post.feature_image.replace('../assets', '/assets')}`
: ogImageUrl.toString();
// Estimate word count and reading time
const textContent = post.body?.raw || '';
const wordCount = textContent.split(/\s+/).filter(Boolean).length;
const readingTime = Math.ceil(wordCount / 200);
// BlogPosting Schema
const blogPostingSchema = {
'@context': 'https://schema.org',
@@ -127,10 +141,30 @@ export default async function BlogPostPage({ params }: Props) {
keywords: post.tags.join(', '),
articleSection: post.tags[0],
}),
...(wordCount > 0 && {
wordCount: wordCount,
readingTime: `${readingTime} min read`,
}),
articleBody: textContent.slice(0, 5000),
inLanguage: siteConfig.defaultLocale,
url: postUrl,
};
// Speakable Schema for AEO
const speakableSchema = {
'@context': 'https://schema.org',
'@type': 'SpeakableSpecification',
speakable: {
'@type': 'CSSSelector',
selector: [
'article[data-toc-content]',
'.prose h2',
'.prose h3',
'.prose p',
],
},
};
// BreadcrumbList Schema
const breadcrumbSchema = {
'@context': 'https://schema.org',
@@ -161,6 +195,7 @@ export default async function BlogPostPage({ params }: Props) {
<>
<JsonLd data={blogPostingSchema} />
<JsonLd data={breadcrumbSchema} />
<JsonLd data={speakableSchema} />
<ReadingProgress />
<PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8">

View File

@@ -1,26 +1,62 @@
import { Link } from 'next-view-transitions';
import { getAllPostsSorted } from '@/lib/posts';
import { PostListWithControls } from '@/components/post-list-with-controls';
import { TimelineWrapper } from '@/components/timeline-wrapper';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal';
import { FiTrendingUp } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import { JsonLd } from '@/components/json-ld';
export const metadata = {
title: '所有文章'
title: '所有文章',
description: '瀏覽所有文章,持續更新中。',
alternates: {
canonical: `${siteConfig.url}/blog`
}
};
export default function BlogIndexPage() {
const posts = getAllPostsSorted();
// Blog schema
const blogSchema = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: '所有文章',
description: '瀏覽所有文章,持續更新中。',
url: `${siteConfig.url}/blog`,
inLanguage: siteConfig.defaultLocale,
blogPost: posts.slice(0, 10).map((post) => ({
'@type': 'BlogPosting',
headline: post.title,
url: `${siteConfig.url}${post.url}`,
datePublished: post.published_at,
dateModified: post.updated_at || post.published_at,
author: {
'@type': 'Person',
name: siteConfig.author
}
}))
};
return (
<section className="space-y-4">
<JsonLd data={blogSchema} />
<SidebarLayout>
<header className="space-y-1">
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</header>
<SectionDivider>
<ScrollReveal>
<header className="space-y-1">
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</header>
</ScrollReveal>
</SectionDivider>
<PostListWithControls posts={posts} />
</SidebarLayout>
</section>