关于 sanity-cms
sanity-cms 是为 Sanity CMS 项目提供的实施技能,面向构建体育俱乐部网站的开发者。它涵盖内容建模、TypeScript 与 GROQ 查询,并提供 Next.js 图片、预览和缓存实践。
主要功能
- 01体育俱乐部内容模型
- 02TypeScript 类型生成与查询
- 03GROQ 筛选、排序和分页
- 04Next.js 图片与草稿预览
- 05缓存、Webhook 与重新验证
适用场景
- 01建模俱乐部新闻、赛程和球员
- 02生成类型安全的内容查询
- 03在 Next.js 中呈现草稿内容
- 04按 CMS 更新重新验证页面
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ dejanvasic85/ williamstownsc --skill sanity-cms安装后在 Claude Code 中输入/sanity-cms确认已加载。
详细介绍
sanity-cms
说明
请按照 https://www.sanity.io/learn/llms.txt 中的文档,在项目中实现 Sanity CMS。此技能提供了针对体育俱乐部内容建模、TypeScript 集成和 Next.js 优化的项目专属模式。
体育俱乐部的内容建模
核心 Schema 类型
Williamstown SC 网站需要以下主要内容类型:
- blogPost - 俱乐部新闻、公告、比赛报道
- event - 比赛、训练课程、俱乐部活动
- player - 球队名单和球员资料
- fixture - 比赛日程、结果和统计数据
- sponsor - 俱乐部赞助商和合作伙伴
- page - 静态页面(关于、联系等)
- teamMember - 教练团队和委员会成员
Schema 最佳实践
命名约定
遵循以下约定以保持一致:
// Schema files: camelCase.ts
blogPost.ts;
teamMember.ts;
fixtureResult.ts;
// Field names: camelCase
publishedAt;
featuredImage;
homeTeamScore;
// Document types: camelCase
blogPost;
teamMember;
fixtureResult;
必填字段模式
每种文档类型都应包含以下基础字段:
{
name: 'blogPost', // or your document type
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required()
},
// _createdAt and _updatedAt are automatic
// Additional fields...
]
}
SEO 元数据模式
适用于所有内容类型的可复用 SEO 对象:
// schemas/objects/seo.ts
export default {
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
{
name: 'metaTitle',
type: 'string',
title: 'Meta Title',
description: 'Title for search engines (50-60 characters)',
validation: (Rule) => Rule.max(60)
},
{
name: 'metaDescription',
type: 'text',
title: 'Meta Description',
description: 'Description for search engines (120-160 characters)',
validation: (Rule) => Rule.min(120).max(160)
},
{
name: 'ogImage',
type: 'image',
title: 'Social Share Image',
description: 'Recommended: 1200x630px'
},
]
}
// Use in document schemas:
{
name: 'seo',
type: 'seo',
title: 'SEO Settings'
}
文档 Schema 示例
博客文章 Schema
// schemas/documents/blogPost.ts
export default {
name: 'blogPost',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'title',
maxLength: 96
},
validation: (Rule) => Rule.required()
},
{
name: 'publishedAt',
type: 'datetime',
title: 'Published At',
validation: (Rule) => Rule.required()
},
{
name: 'excerpt',
type: 'text',
title: 'Excerpt',
description: 'Short summary for cards and previews',
rows: 3,
validation: (Rule) => Rule.max(200)
},
{
name: 'mainImage',
type: 'image',
title: 'Main Image',
options: {
hotspot: true
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
}
]
},
{
name: 'categories',
type: 'array',
title: 'Categories',
of: [{ type: 'reference', to: [{ type: 'category' }] }]
},
{
name: 'body',
type: 'array',
title: 'Body',
of: [
{ type: 'block' },
{
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text'
}
]
}
]
},
{
name: 'featured',
type: 'boolean',
title: 'Featured Post',
description: 'Display on homepage'
},
{
name: 'seo',
type: 'seo',
title: 'SEO Settings'
}
],
preview: {
select: {
title: 'title',
media: 'mainImage',
subtitle: 'publishedAt'
}
}
};
赛程/比赛结果 Schema
// schemas/documents/fixture.ts
export default {
name: 'fixture',
title: 'Fixture',
type: 'document',
fields: [
{
name: 'matchDate',
type: 'datetime',
title: 'Match Date & Time',
validation: (Rule) => Rule.required()
},
{
name: 'competition',
type: 'string',
title: 'Competition',
options: {
list: [
{ title: 'NPL Victoria', value: 'npl' },
{ title: 'FFA Cup', value: 'ffa-cup' },
{ title: 'State League', value: 'state-league' }
]
}
},
{
name: 'homeTeam',
type: 'string',
title: 'Home Team',
validation: (Rule) => Rule.required()
},
{
name: 'awayTeam',
type: 'string',
title: 'Away Team',
validation: (Rule) => Rule.required()
},
{
name: 'homeScore',
type: 'number',
title: 'Home Score',
description: 'Leave empty for upcoming matches'
},
{
name: 'awayScore',
type: 'number',
title: 'Away Score',
description: 'Leave empty for upcoming matches'
},
{
name: 'venue',
type: 'string',
title: 'Venue',
validation: (Rule) => Rule.required()
},
{
name: 'isHomeGame',
type: 'boolean',
title: 'Is Home Game',
description: 'Is this a Williamstown SC home game?'
},
{
name: 'matchReport',
type: 'array',
title: 'Match Report',
description: 'Detailed match report (optional)',
of: [{ type: 'block' }]
},
{
name: 'highlights',
type: 'url',
title: 'Highlights Video URL',
description: 'YouTube or other video platform URL'
}
],
preview: {
select: {
homeTeam: 'homeTeam',
awayTeam: 'awayTeam',
homeScore: 'homeScore',
awayScore: 'awayScore',
date: 'matchDate'
},
prepare({ homeTeam, awayTeam, homeScore, awayScore, date }) {
const score =
homeScore !== undefined && awayScore !== undefined ? `${homeScore}-${awayScore}` : 'vs';
return {
title: `${homeTeam} ${score} ${awayTeam}`,
subtitle: new Date(date).toLocaleDateString()
};
}
}
};
球员资料 Schema
// schemas/documents/player.ts
export default {
name: 'player',
title: 'Player',
type: 'document',
fields: [
{
name: 'name',
type: 'string',
title: 'Full Name',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'name',
maxLength: 96
}
},
{
name: 'number',
type: 'number',
title: 'Squad Number',
validation: (Rule) => Rule.min(1).max(99)
},
{
name: 'position',
type: 'string',
title: 'Position',
options: {
list: [
{ title: 'Goalkeeper', value: 'GK' },
{ title: 'Defender', value: 'DEF' },
{ title: 'Midfielder', value: 'MID' },
{ title: 'Forward', value: 'FWD' }
]
},
validation: (Rule) => Rule.required()
},
{
name: 'photo',
type: 'image',
title: 'Player Photo',
options: {
hotspot: true
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
}
]
},
{
name: 'bio',
type: 'text',
title: 'Biography',
rows: 4
},
{
name: 'stats',
type: 'object',
title: 'Season Statistics',
fields: [
{
name: 'appearances',
type: 'number',
title: 'Appearances',
initialValue: 0
},
{
name: 'goals',
type: 'number',
title: 'Goals',
initialValue: 0
},
{
name: 'assists',
type: 'number',
title: 'Assists',
initialValue: 0
}
]
}
],
preview: {
select: {
title: 'name',
number: 'number',
position: 'position',
media: 'photo'
},
prepare({ title, number, position, media }) {
return {
title: `${number ? `#${number} ` : ''}${title}`,
subtitle: position,
media
};
}
}
};
TypeScript 集成
生成类型
添加到你的 package.json:
{
"scripts": {
"sanity:typegen": "sanity schema extract && sanity typegen generate"
}
}
修改 schema 后运行:
npm run sanity:typegen
使用生成的类型
// Import generated types
import type {BlogPost, Fixture, Player} from '@/sanity/types'
// Type-safe data fetching
const posts: BlogPost[] = await client.fetch(query)
// Type-safe component props
interface NewsCardProps {
post: BlogPost
}
const NewsCard = ({post}: NewsCardProps) => {
return (
<article>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
)
}
类型安全的 GROQ 查询
import { groq } from 'next-sanity';
import type { BlogPost } from '@/sanity/types';
const query = groq`
*[_type == "blogPost"] | order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
excerpt,
"mainImage": mainImage.asset->url,
"categories": categories[]->title
}
`;
const posts = await client.fetch<BlogPost[]>(query);
GROQ 查询模式
常用查询
最新博客文章
*[_type == "blogPost"] | order(publishedAt desc)[0...10] {
_id,
title,
slug,
excerpt,
publishedAt,
"image": mainImage.asset->url,
"imageAlt": mainImage.alt,
"categories": categories[]->title,
featured
}
即将举行的比赛
*[_type == "fixture" && matchDate > now()] | order(matchDate asc) {
_id,
matchDate,
homeTeam,
awayTeam,
venue,
competition,
isHomeGame
}
过往比赛结果
*[_type == "fixture" && matchDate < now() && defined(homeScore)] | order(matchDate desc)[0...10] {
_id,
matchDate,
homeTeam,
awayTeam,
homeScore,
awayScore,
venue,
isHomeGame
}
按位置筛选的球队名单
*[_type == "player"] | order(position asc, number asc) {
_id,
name,
number,
position,
"photo": photo.asset->url,
"photoAlt": photo.alt,
stats
}
包含完整内容的单篇文章
*[_type == "blogPost" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
body,
"mainImage": mainImage.asset->url,
"mainImageAlt": mainImage.alt,
"categories": categories[]->{
_id,
title,
slug
},
seo
}
引用展开
// Single reference with ->
"author": author->name,
"category": category->title,
// Array of references with []->
"tags": tags[]->title,
"players": players[]-> {
name,
number,
position
},
// Nested references
"author": author-> {
name,
"image": image.asset->url
}
筛选与排序
// Filter by multiple conditions
*[_type == "blogPost" && featured == true && publishedAt < now()]
// Filter with references
*[_type == "blogPost" && references(*[_type == "category" && title == "News"]._id)]
// Date filtering
*[_type == "fixture" && matchDate >= $startDate && matchDate <= $endDate]
// Sorting
| order(publishedAt desc)
| order(matchDate asc)
| order(position asc, number asc) // Multiple fields
分页
// First 10 results
*[_type == "blogPost"] | order(publishedAt desc)[0...10]
// Next 10 results (11-20)
*[_type == "blogPost"] | order(publishedAt desc)[10...20]
// Using variables
*[_type == "blogPost"] | order(publishedAt desc)[$start...$end]
图像优化
带验证的图像 Schema
{
name: 'mainImage',
type: 'image',
title: 'Main Image',
options: {
hotspot: true, // Enable focal point selection
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
description: 'Describe the image for accessibility',
validation: (Rule) => Rule.required().error('Alt text is required for accessibility')
},
{
name: 'caption',
type: 'string',
title: 'Caption',
description: 'Optional caption to display below image'
}
],
validation: (Rule) => Rule.required()
}
图像 URL 构建器
// lib/sanity/image.ts
import imageUrlBuilder from '@sanity/image-url';
// Usage:
import { urlFor } from '@/lib/sanity/image';
import { client } from './client';
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
const imageUrl = urlFor(post.mainImage).width(800).height(600).fit('crop').url();
Next.js 图像集成
import Image from 'next/image';
import { urlFor } from '@/lib/sanity/image';
<Image
src={urlFor(post.mainImage).width(800).height(600).url()}
alt={post.mainImage.alt}
width={800}
height={600}
className="rounded-lg"
/>;
响应式图像
// Generate srcset for responsive images
function getImageSrcSet(image: any, widths: number[] = [400, 800, 1200]) {
return widths.map(width =>
`${urlFor(image).width(width).url()} ${width}w`
).join(', ')
}
// Usage in component:
<img
src={urlFor(image).width(800).url()}
srcSet={getImageSrcSet(image)}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
alt={image.alt}
/>
Portable Text(富文本)
Schema 配置
{
name: 'body',
title: 'Body',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
{title: 'Underline', value: 'underline'},
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Link',
fields: [
{
name: 'href',
type: 'url',
title: 'URL',
validation: (Rule) => Rule.required()
}
]
}
]
}
},
{
type: 'image',
options: {hotspot: true},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: (Rule) => Rule.required()
},
{
name: 'caption',
type: 'string',
title: 'Caption'
}
]
}
]
}
渲染 Portable Text
import {PortableText} from '@portabletext/react'
import Image from 'next/image'
import {urlFor} from '@/lib/sanity/image'
const components = {
block: {
h1: ({children}) => (
<h1 className="text-4xl font-bold mb-4">{children}</h1>
),
h2: ({children}) => (
<h2 className="text-3xl font-bold mb-3">{children}</h2>
),
h3: ({children}) => (
<h3 className="text-2xl font-bold mb-2">{children}</h3>
),
normal: ({children}) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
},
marks: {
link: ({children, value}) => (
<a
href={value.href}
className="text-primary underline hover:text-primary-focus"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
},
types: {
image: ({value}) => (
<figure className="my-8">
<Image
src={urlFor(value).width(1200).url()}
alt={value.alt || 'Blog post image'}
width={1200}
height={800}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-sm text-center mt-2 text-base-content/70">
{value.caption}
</figcaption>
)}
</figure>
),
},
}
// Usage:
<PortableText value={post.body} components={components} />
预览与草稿模式
在 Next.js 中启用草稿模式
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Verify secret token
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
// Enable draft mode
draftMode().enable();
// Redirect to the path
redirect(slug || '/');
}
禁用草稿模式
// app/api/exit-draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
draftMode().disable();
redirect('/');
}
获取草稿内容
import { draftMode } from 'next/headers';
import { client } from '@/lib/sanity/client';
export async function getPosts() {
const preview = draftMode().isEnabled;
const posts = await client.fetch(
query,
{},
{
perspective: preview ? 'previewDrafts' : 'published',
// Disable caching in preview mode
cache: preview ? 'no-store' : 'force-cache',
next: {
revalidate: preview ? 0 : 3600
}
}
);
return posts;
}
Schema 组织结构
推荐的目录结构
sanity/
├── schemas/
│ ├── documents/ # Top-level content types
│ │ ├── blogPost.ts
│ │ ├── event.ts
│ │ ├── fixture.ts
│ │ ├── page.ts
│ │ ├── player.ts
│ │ └── sponsor.ts
│ ├── objects/ # Reusable objects
│ │ ├── seo.ts
│ │ ├── socialLinks.ts
│ │ └── stats.ts
│ └── index.ts # Export all schemas
├── lib/
│ ├── client.ts # Sanity client config
│ └── image.ts # Image URL builder
├── env.ts # Environment variables
└── types.ts # Generated TypeScript types
Schema 索引文件
// schemas/index.ts
import blogPost from './documents/blogPost';
import event from './documents/event';
import fixture from './documents/fixture';
import player from './documents/player';
import seo from './objects/seo';
export const schemaTypes = [
// Documents
blogPost,
event,
fixture,
player,
// Objects
seo
];
验证模式
常见验证
// Required field
validation: (Rule) => Rule.required();
// String length
validation: (Rule) => Rule.min(50).max(160);
// Number range
validation: (Rule) => Rule.min(0).max(100);
// URL validation
validation: (Rule) =>
Rule.uri({
scheme: ['http', 'https']
});
// Custom validation
validation: (Rule) =>
Rule.custom((value) => {
if (!value) {
return 'This field is required';
}
if (value.length < 10) {
return 'Must be at least 10 characters';
}
return true;
});
// Conditional validation
validation: (Rule) =>
Rule.custom((value, context) => {
if (context.document.featured && !value) {
return 'Featured posts must have an excerpt';
}
return true;
});
内容关系
引用
// Single reference
{
name: 'author',
title: 'Author',
type: 'reference',
to: [{type: 'person'}],
validation: (Rule) => Rule.required()
}
// Multiple references
{
name: 'categories',
title: 'Categories',
type: 'array',
of: [{type: 'reference', to: [{type: 'category'}]}]
}
// Reference with preview
{
name: 'relatedPosts',
title: 'Related Posts',
type: 'array',
of: [
{
type: 'reference',
to: [{type: 'blogPost'}],
options: {
filter: '_id != $id',
filterParams: {id: '_id'}
}
}
]
}
查询引用
// Expand single reference
"author": author-> {
name,
"image": image.asset->url
}
// Expand array of references
"categories": categories[]-> {
_id,
title,
slug
}
// Filter by reference
*[_type == "blogPost" && references(*[_type == "category" && slug.current == $categorySlug]._id)]
性能优化
客户端配置
// lib/sanity/client.ts
import { createClient } from 'next-sanity';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
perspective: 'published'
});
Next.js 缓存
// On-demand revalidation
import { revalidateTag } from 'next/cache';
// Fetch with caching
const posts = await client.fetch(
query,
{},
{
cache: 'force-cache',
next: {
revalidate: 3600, // Revalidate every hour
tags: ['posts'] // Tag for on-demand revalidation
}
}
);
export async function POST(request: Request) {
revalidateTag('posts');
return Response.json({ revalidated: true });
}
GROQ 查询优化
// Use select() to limit fields
*[_type == "blogPost"]{
_id,
title,
slug,
publishedAt
}
// Avoid fetching large fields unless needed
*[_type == "blogPost"]{
..., // All fields
body // Exclude this for list views
}
// Use pagination
*[_type == "blogPost"] | order(publishedAt desc)[0...10]
// Limit reference depth
"author": author->{name} // Only fetch name, not entire document
Webhook 与实时更新
配置 Sanity Webhook
在 Sanity 控制面板中配置 webhook:
- 前往 API → Webhooks
- 添加 webhook URL:
https://yoursite.com/api/revalidate - 选择数据集和事件(创建、更新、删除)
Next.js 重新验证端点
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-sanity-webhook-secret');
// Verify webhook secret
if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
// Revalidate based on document type
const { _type } = body;
if (_type === 'blogPost') {
revalidateTag('posts');
revalidatePath('/news');
}
if (_type === 'fixture') {
revalidateTag('fixtures');
revalidatePath('/fixtures');
}
return NextResponse.json({ revalidated: true });
}
最佳实践
内容建模
- 保持 schema 专注 - 每个关注点使用一种文档类型
- 使用对象以便复用 - SEO、社交链接等
- 添加描述 - 帮助内容编辑者了解字段
- 设置合理的默认值 - 对常见值使用
initialValue - 全面验证 - 在输入时防止错误数据
TypeScript
- 修改 schema 后生成类型 - 保持类型同步
- 使用类型守卫 - 在运行时验证数据结构
- 为查询结果指定类型 - 为获取数据的调用添加类型注解
性能
- 使用 CDN 提供图像 - Sanity 会自动通过 CDN 提供图像
- 实现 ISR - 使用 Next.js 重新验证来获取最新内容
- 限制查询字段 - 仅获取所需内容
- 对大型数据集分页 - 不要一次性获取所有内容
安全
- 切勿暴露令牌 - 使用环境变量
- 验证 webhook 密钥 - 验证传入请求
- 清理用户输入 - 即使内容来自 CMS(Portable Text 默认也是安全的)
常见误区
❌ 不要:
- 只需要几个字段时却获取整个文档
- 存储可以计算得出的值
- 创建过深的嵌套架构(最多 3-4 层)
- 简单的字符串字段就能满足需求时却使用引用
- 图片缺少替代文本
✅ 应该:
- 使用 GROQ 投影来限制字段
- 在查询或组件中计算派生值
- 尽可能保持架构扁平
- 只有在需要共享或更新内容时才使用引用
- 始终要求提供替代文本,以满足无障碍访问需求
快速参考
GROQ 语法
*[filter] | order(field direction)[range] {projection}
// Examples:
*[_type == "blogPost"] // All blog posts
*[_type == "blogPost" && featured] // Filtered
| order(publishedAt desc) // Sorted
[0...10] // Paginated
{title, slug, "image": mainImage.asset->url} // Projected
常见字段类型
string, text, number, boolean, datetime, date
slug, url, email
image, file
array, object
reference
block (Portable Text)
验证方法
required(), min(), max(), length(), regex(), email(), url(), custom()
评论
支持 Markdown