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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user