Add sitemap and SEO metadata

This commit is contained in:
yarnom 2026-06-01 17:22:04 +08:00
parent 21551e3a97
commit b4185eb668
14 changed files with 393 additions and 27 deletions

View file

@ -3,6 +3,7 @@ const links = [
{ href: '/', label: '首页' }, { href: '/', label: '首页' },
{ href: '/archive/', label: '归档' }, { href: '/archive/', label: '归档' },
{ href: '/tags/', label: '标签' }, { href: '/tags/', label: '标签' },
{ href: '/site-map/', label: '站点地图' },
{ href: '/rss.xml', label: 'RSS' } { href: '/rss.xml', label: 'RSS' }
]; ];
--- ---

View file

@ -0,0 +1,57 @@
---
import { site } from '../../lib/siteConfig';
import { absoluteUrl } from '../../lib/seo';
type JsonLd = Record<string, unknown> | Record<string, unknown>[];
type Props = {
title?: string;
description?: string;
path?: string;
type?: 'website' | 'article';
publishedTime?: string;
modifiedTime?: string;
tags?: string[];
jsonLd?: JsonLd;
};
const {
title = site.title,
description = site.description,
path = '/',
type = 'website',
publishedTime,
modifiedTime,
tags = [],
jsonLd
} = Astro.props;
const pageTitle = title === site.title ? site.title : `${title} | ${site.title}`;
const canonicalUrl = absoluteUrl(path);
const rssUrl = absoluteUrl('/rss.xml');
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{pageTitle}</title>
<meta name="description" content={description} />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} />
<meta property="og:site_name" content={site.title} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:locale" content={site.language.replace('-', '_')} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={description} />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{tags.map((tag) => <meta property="article:tag" content={tag} />)}
{
jsonLd && (
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
)
}

View file

@ -0,0 +1,21 @@
import { site } from './siteConfig';
import type { Post } from './posts';
export function absoluteUrl(path = '/'): string {
const base = site.base_url.replace(/\/+$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${base}${normalizedPath}`;
}
export function postUpdatedAt(post: Pick<Post, 'updated_at' | 'published_at' | 'created_at' | 'date'>): string {
return post.updated_at ?? post.published_at ?? post.created_at ?? post.date ?? '';
}
export function xmlEscape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View file

@ -47,14 +47,26 @@ function loadSiteConfig(): SiteConfig {
try { try {
const file = readFileSync(resolve(siteRoot, 'config/site.yaml'), 'utf8'); const file = readFileSync(resolve(siteRoot, 'config/site.yaml'), 'utf8');
const parsed = YAML.parse(file) ?? {}; const parsed = YAML.parse(file) ?? {};
const siteOverrides = {
...parsed.site,
...(process.env.SITE_URL ? { base_url: process.env.SITE_URL } : {}),
...(process.env.PUBLIC_SITE_URL ? { base_url: process.env.PUBLIC_SITE_URL } : {})
};
return { return {
site: { ...defaults.site, ...parsed.site }, site: { ...defaults.site, ...siteOverrides },
content: { ...defaults.content, ...parsed.content }, content: { ...defaults.content, ...parsed.content },
build: { ...defaults.build, ...parsed.build } build: { ...defaults.build, ...parsed.build }
}; };
} catch { } catch {
return defaults; return {
...defaults,
site: {
...defaults.site,
...(process.env.SITE_URL ? { base_url: process.env.SITE_URL } : {}),
...(process.env.PUBLIC_SITE_URL ? { base_url: process.env.PUBLIC_SITE_URL } : {})
}
};
} }
} }

View file

@ -2,21 +2,20 @@
import '../../styles/normalize.css'; import '../../styles/normalize.css';
import DefaultArchive from '../../components/DefaultArchive.astro'; import DefaultArchive from '../../components/DefaultArchive.astro';
import YarArchive from '../../components/themes/yar/YarArchive.astro'; import YarArchive from '../../components/themes/yar/YarArchive.astro';
import SeoHead from '../../components/seo/SeoHead.astro';
import { site } from '../../lib/siteConfig'; import { site } from '../../lib/siteConfig';
import { getArchiveYears } from '../../lib/posts'; import { getArchiveYears } from '../../lib/posts';
const archiveYears = getArchiveYears(); const archiveYears = getArchiveYears();
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const ArchiveView = theme === 'yar' ? YarArchive : DefaultArchive; const ArchiveView = theme === 'yar' ? YarArchive : DefaultArchive;
const description = `${site.title} 的文章归档`;
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead title="归档" description={description} path="/archive/" />
<meta name="viewport" content="width=device-width" />
<title>Archive | {site.title}</title>
<meta name="description" content={`Archive of ${site.title}`} />
</head> </head>
<body> <body>
<ArchiveView archiveYears={archiveYears} /> <ArchiveView archiveYears={archiveYears} />

View file

@ -2,23 +2,30 @@
import '../styles/normalize.css'; import '../styles/normalize.css';
import DefaultHome from '../components/DefaultHome.astro'; import DefaultHome from '../components/DefaultHome.astro';
import YarHome from '../components/themes/yar/YarHome.astro'; import YarHome from '../components/themes/yar/YarHome.astro';
import SeoHead from '../components/seo/SeoHead.astro';
import { site } from '../lib/siteConfig'; import { site } from '../lib/siteConfig';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../lib/posts'; import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../lib/posts';
import { absoluteUrl } from '../lib/seo';
const allPosts = getPublishedPosts(); const allPosts = getPublishedPosts();
const posts = getPaginatedPosts(allPosts, 1); const posts = getPaginatedPosts(allPosts, 1);
const totalPages = getTotalPages(allPosts); const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome; const Home = theme === 'yar' ? YarHome : DefaultHome;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: site.title,
description: site.description,
url: absoluteUrl('/'),
inLanguage: site.language
};
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead title={site.title} description={site.description} path="/" jsonLd={jsonLd} />
<meta name="viewport" content="width=device-width" />
<title>{site.title}</title>
<meta name="description" content={site.description} />
</head> </head>
<body> <body>
<Home posts={posts} currentPage={1} totalPages={totalPages} /> <Home posts={posts} currentPage={1} totalPages={totalPages} />

View file

@ -2,6 +2,7 @@
import '../../styles/normalize.css'; import '../../styles/normalize.css';
import DefaultHome from '../../components/DefaultHome.astro'; import DefaultHome from '../../components/DefaultHome.astro';
import YarHome from '../../components/themes/yar/YarHome.astro'; import YarHome from '../../components/themes/yar/YarHome.astro';
import SeoHead from '../../components/seo/SeoHead.astro';
import { site } from '../../lib/siteConfig'; import { site } from '../../lib/siteConfig';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../../lib/posts'; import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../../lib/posts';
@ -24,15 +25,14 @@ const posts = getPaginatedPosts(allPosts, page);
const totalPages = getTotalPages(allPosts); const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome; const Home = theme === 'yar' ? YarHome : DefaultHome;
const title = `第 ${page} 页`;
const description = `${site.title} 的第 ${page} 页文章列表`;
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead title={title} description={description} path={`/page/${page}/`} />
<meta name="viewport" content="width=device-width" />
<title>第 {page} 页 | {site.title}</title>
<meta name="description" content={site.description} />
</head> </head>
<body> <body>
<Home posts={posts} currentPage={page} totalPages={totalPages} /> <Home posts={posts} currentPage={page} totalPages={totalPages} />

View file

@ -2,7 +2,9 @@
import '../../styles/normalize.css'; import '../../styles/normalize.css';
import DefaultPost from '../../components/DefaultPost.astro'; import DefaultPost from '../../components/DefaultPost.astro';
import YarPost from '../../components/themes/yar/YarPost.astro'; import YarPost from '../../components/themes/yar/YarPost.astro';
import SeoHead from '../../components/seo/SeoHead.astro';
import { site } from '../../lib/siteConfig'; import { site } from '../../lib/siteConfig';
import { absoluteUrl } from '../../lib/seo';
export function getStaticPaths() { export function getStaticPaths() {
const modules = import.meta.glob('../../../../../content/posts/*.md', { eager: true }); const modules = import.meta.glob('../../../../../content/posts/*.md', { eager: true });
@ -18,15 +20,46 @@ const { post } = Astro.props;
const title = post.frontmatter.title; const title = post.frontmatter.title;
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const PostView = theme === 'yar' ? YarPost : DefaultPost; const PostView = theme === 'yar' ? YarPost : DefaultPost;
const description = post.frontmatter.summary ?? site.description;
const publishedTime = post.frontmatter.published_at ?? post.frontmatter.created_at;
const modifiedTime = post.frontmatter.updated_at ?? publishedTime;
const postUrl = `/posts/${post.frontmatter.slug}/`;
const tags = post.frontmatter.tags ?? [];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: publishedTime,
dateModified: modifiedTime,
url: absoluteUrl(postUrl),
mainEntityOfPage: absoluteUrl(postUrl),
inLanguage: site.language,
author: {
'@type': 'Person',
name: site.title
},
publisher: {
'@type': 'Organization',
name: site.title
},
...(tags.length > 0 ? { keywords: tags.join(', ') } : {})
};
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead
<meta name="viewport" content="width=device-width" /> title={title}
<title>{title} | {site.title}</title> description={description}
{post.frontmatter.summary && <meta name="description" content={post.frontmatter.summary} />} path={postUrl}
type="article"
publishedTime={publishedTime}
modifiedTime={modifiedTime}
tags={tags}
jsonLd={jsonLd}
/>
</head> </head>
<body> <body>
<PostView post={post} /> <PostView post={post} />

View file

@ -0,0 +1,14 @@
import { absoluteUrl } from '../lib/seo';
export function GET() {
return new Response(`User-agent: *
Allow: /
Sitemap: ${absoluteUrl('/sitemap.xml')}
`, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600'
}
});
}

View file

@ -1,6 +1,7 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { getPublishedPosts } from '../lib/posts'; import { getPublishedPosts } from '../lib/posts';
import { site } from '../lib/siteConfig'; import { site } from '../lib/siteConfig';
import { absoluteUrl } from '../lib/seo';
export function GET() { export function GET() {
return rss({ return rss({
@ -10,7 +11,7 @@ export function GET() {
items: getPublishedPosts().map((post) => ({ items: getPublishedPosts().map((post) => ({
title: post.title, title: post.title,
description: post.summary ?? '', description: post.summary ?? '',
link: post.url, link: absoluteUrl(post.url),
pubDate: post.date ? new Date(post.date) : undefined, pubDate: post.date ? new Date(post.date) : undefined,
categories: post.tags categories: post.tags
})) }))

View file

@ -0,0 +1,150 @@
---
import '../styles/normalize.css';
import SiteNav from '../components/SiteNav.astro';
import SeoHead from '../components/seo/SeoHead.astro';
import { site } from '../lib/siteConfig';
import { getArchiveYears, getTagSummaries } from '../lib/posts';
const archiveYears = getArchiveYears();
const tags = getTagSummaries();
const description = `${site.title} 的文章、归档和标签站点地图`;
---
<!doctype html>
<html lang={site.language}>
<head>
<SeoHead title="站点地图" description={description} path="/site-map/" />
</head>
<body>
<main class="site-map">
<SiteNav />
<header>
<h1>站点地图</h1>
<p>{description}</p>
</header>
<section aria-labelledby="main-pages">
<h2 id="main-pages">主要页面</h2>
<ul>
<li><a href="/">首页</a></li>
<li><a href="/archive/">归档</a></li>
<li><a href="/tags/">标签</a></li>
<li><a href="/rss.xml">RSS</a></li>
<li><a href="/sitemap.xml">XML Sitemap</a></li>
</ul>
</section>
{tags.length > 0 && (
<section aria-labelledby="tag-pages">
<h2 id="tag-pages">标签</h2>
<ul class="columns">
{tags.map((tag) => (
<li><a href={`/tags/${tag.slug}/`}>{tag.name}</a> <span>({tag.count})</span></li>
))}
</ul>
</section>
)}
<section aria-labelledby="post-pages">
<h2 id="post-pages">文章</h2>
{archiveYears.map((year) => (
<div class="year-group">
<h3>{year.year}</h3>
<ul>
{year.posts.map((post) => (
<li>
<a href={post.url}>{post.title}</a>
{post.date && <time datetime={post.date}>{new Date(post.date).toLocaleDateString(site.language)}</time>}
</li>
))}
</ul>
</div>
))}
</section>
</main>
</body>
</html>
<style>
.site-map {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.site-map :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
a {
color: inherit;
text-underline-offset: 4px;
}
header {
border-bottom: 1px solid #d8d1c3;
margin-bottom: 32px;
padding-bottom: 24px;
}
h1 {
margin: 0 0 12px;
font-size: 2.5rem;
line-height: 1.1;
}
h2 {
margin: 32px 0 12px;
font-size: 1.35rem;
}
h3 {
margin: 20px 0 8px;
color: #625a50;
font-size: 1rem;
}
p {
margin: 0;
color: #5b554d;
line-height: 1.7;
}
ul {
display: grid;
gap: 10px;
margin: 0;
padding-left: 1.2rem;
}
.columns {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
li {
line-height: 1.6;
}
time,
span {
color: #766e63;
font-size: 0.9rem;
}
time {
margin-left: 10px;
}
.year-group + .year-group {
margin-top: 16px;
}
</style>

View file

@ -0,0 +1,73 @@
import { getPublishedPosts, getTagSummaries, getTotalPages } from '../lib/posts';
import { absoluteUrl, postUpdatedAt, xmlEscape } from '../lib/seo';
import { site } from '../lib/siteConfig';
type SitemapEntry = {
path: string;
lastmod?: string;
changefreq?: 'daily' | 'weekly' | 'monthly' | 'yearly';
priority?: string;
};
function formatLastmod(value?: string): string | undefined {
if (!value) {
return undefined;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return undefined;
}
return date.toISOString();
}
export function GET() {
const posts = getPublishedPosts();
const latestPostDate = posts.map(postUpdatedAt).sort().at(-1);
const entries: SitemapEntry[] = [
{ path: '/', lastmod: latestPostDate, changefreq: 'daily', priority: '1.0' },
{ path: '/archive/', lastmod: latestPostDate, changefreq: 'weekly', priority: '0.7' },
{ path: '/tags/', lastmod: latestPostDate, changefreq: 'weekly', priority: '0.6' },
{ path: '/site-map/', lastmod: latestPostDate, changefreq: 'weekly', priority: '0.5' },
...Array.from({ length: Math.max(0, getTotalPages(posts) - 1) }, (_, index) => ({
path: `/page/${index + 2}/`,
lastmod: latestPostDate,
changefreq: 'weekly' as const,
priority: '0.6'
})),
...getTagSummaries().map((tag) => ({
path: `/tags/${tag.slug}/`,
lastmod: latestPostDate,
changefreq: 'weekly' as const,
priority: '0.5'
})),
...posts.map((post) => ({
path: post.url,
lastmod: postUpdatedAt(post),
changefreq: 'monthly' as const,
priority: '0.8'
}))
];
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries
.map((entry) => {
const lastmod = formatLastmod(entry.lastmod);
return ` <url>
<loc>${xmlEscape(absoluteUrl(entry.path))}</loc>${lastmod ? `\n <lastmod>${lastmod}</lastmod>` : ''}
<changefreq>${entry.changefreq ?? 'weekly'}</changefreq>
<priority>${entry.priority ?? '0.5'}</priority>
</url>`;
})
.join('\n')}
</urlset>`;
return new Response(body, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600'
}
});
}

View file

@ -2,6 +2,7 @@
import '../../styles/normalize.css'; import '../../styles/normalize.css';
import DefaultTagPosts from '../../components/DefaultTagPosts.astro'; import DefaultTagPosts from '../../components/DefaultTagPosts.astro';
import YarTagPosts from '../../components/themes/yar/YarTagPosts.astro'; import YarTagPosts from '../../components/themes/yar/YarTagPosts.astro';
import SeoHead from '../../components/seo/SeoHead.astro';
import { site } from '../../lib/siteConfig'; import { site } from '../../lib/siteConfig';
import { getPostsByTag, getTagSummaries } from '../../lib/posts'; import { getPostsByTag, getTagSummaries } from '../../lib/posts';
@ -16,15 +17,13 @@ const { tag } = Astro.props;
const posts = getPostsByTag(tag.slug); const posts = getPostsByTag(tag.slug);
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagPostsView = theme === 'yar' ? YarTagPosts : DefaultTagPosts; const TagPostsView = theme === 'yar' ? YarTagPosts : DefaultTagPosts;
const description = `${site.title} 中带有「${tag.name}」标签的文章`;
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead title={tag.name} description={description} path={`/tags/${tag.slug}/`} />
<meta name="viewport" content="width=device-width" />
<title>{tag.name} | {site.title}</title>
<meta name="description" content={`Posts tagged ${tag.name}`} />
</head> </head>
<body> <body>
<TagPostsView tag={tag} posts={posts} /> <TagPostsView tag={tag} posts={posts} />

View file

@ -2,21 +2,20 @@
import '../../styles/normalize.css'; import '../../styles/normalize.css';
import DefaultTags from '../../components/DefaultTags.astro'; import DefaultTags from '../../components/DefaultTags.astro';
import YarTags from '../../components/themes/yar/YarTags.astro'; import YarTags from '../../components/themes/yar/YarTags.astro';
import SeoHead from '../../components/seo/SeoHead.astro';
import { site } from '../../lib/siteConfig'; import { site } from '../../lib/siteConfig';
import { getTagSummaries } from '../../lib/posts'; import { getTagSummaries } from '../../lib/posts';
const tags = getTagSummaries(); const tags = getTagSummaries();
const theme = site.theme?.trim().toLowerCase() ?? 'default'; const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagsView = theme === 'yar' ? YarTags : DefaultTags; const TagsView = theme === 'yar' ? YarTags : DefaultTags;
const description = `${site.title} 的全部文章标签`;
--- ---
<!doctype html> <!doctype html>
<html lang={site.language}> <html lang={site.language}>
<head> <head>
<meta charset="utf-8" /> <SeoHead title="标签" description={description} path="/tags/" />
<meta name="viewport" content="width=device-width" />
<title>Tags | {site.title}</title>
<meta name="description" content={`Tags on ${site.title}`} />
</head> </head>
<body> <body>
<TagsView tags={tags} /> <TagsView tags={tags} />