mcpField
S
sanity

sanity

github.com/mindrally開発287730 回インストール3 週間前に更新
リポジトリ

sanity について

Sanity CMS、GROQ、TypeScript を使った開発向けのスキルです。ヘッドレス CMS のスキーマ、クエリ、クライアント設定、検証、カスタムコンポーネントを扱います。

主な機能

  1. 01スキーマとコンテンツモデリング
  2. 02GROQ クエリのパターン
  3. 03TypeScript クライアント連携
  4. 04フィールド検証とプレビュー設定
  5. 05カスタム入力コンポーネント

活用シーン

  1. 01ドキュメントとオブジェクトのスキーマ定義
  2. 02GROQ による型付きコンテンツ取得
  3. 03CMS フィールドへの検証追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mindrally/skills --skill sanity

インストール後、Claude Code で/sanityを入力して読み込まれたか確認します。

詳細

Sanity CMS 開発

Sanity CMS、GROQ クエリ、TypeScript 統合、ヘッドレス CMS アーキテクチャのエキスパートです。

基本原則

  • コンテンツモデリングのベストプラクティスに沿ってスキーマを設計する
  • 効率的な GROQ クエリを作成する
  • 型安全性を確保するために TypeScript を使用する
  • スケーラビリティを考慮してプロジェクトを構成する
  • 適切なバリデーションとプレビューを実装する

プロジェクト構成

sanity/
├── schemas/
│   ├── documents/
│   │   ├── post.ts
│   │   └── author.ts
│   ├── objects/
│   │   ├── blockContent.ts
│   │   └── image.ts
│   └── index.ts
├── lib/
│   ├── client.ts
│   └── queries.ts
├── components/
│   └── previews/
└── sanity.config.ts

スキーマ定義

ドキュメントタイプ
// schemas/documents/post.ts
import { defineType, defineField } from 'sanity';

export default defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required().min(10).max(80),
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: {
        source: 'title',
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'author',
      title: 'Author',
      type: 'reference',
      to: [{ type: 'author' }],
    }),
    defineField({
      name: 'publishedAt',
      title: 'Published at',
      type: 'datetime',
    }),
    defineField({
      name: 'body',
      title: 'Body',
      type: 'blockContent',
    }),
  ],
  preview: {
    select: {
      title: 'title',
      author: 'author.name',
      media: 'mainImage',
    },
    prepare({ title, author, media }) {
      return {
        title,
        subtitle: author ? `by ${author}` : '',
        media,
      };
    },
  },
});
オブジェクトタイプ
// schemas/objects/blockContent.ts
import { defineType, defineArrayMember } from 'sanity';

export default defineType({
  name: 'blockContent',
  title: 'Block Content',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      styles: [
        { title: 'Normal', value: 'normal' },
        { title: 'H2', value: 'h2' },
        { title: 'H3', value: 'h3' },
        { title: 'Quote', value: 'blockquote' },
      ],
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
          { title: 'Code', value: 'code' },
        ],
        annotations: [
          {
            name: 'link',
            type: 'object',
            title: 'URL',
            fields: [
              {
                name: 'href',
                type: 'url',
                title: 'URL',
              },
            ],
          },
        ],
      },
    }),
    defineArrayMember({
      type: 'image',
      options: { hotspot: true },
    }),
  ],
});

GROQ クエリ

基本的なクエリ
// lib/queries.ts

// Get all posts
export const allPostsQuery = groq`
  *[_type == "post"] | order(publishedAt desc) {
    _id,
    title,
    slug,
    publishedAt,
    "author": author->name,
    "imageUrl": mainImage.asset->url
  }
`;

// Get single post by slug
export const postBySlugQuery = groq`
  *[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    body,
    publishedAt,
    "author": author->{name, image},
    "categories": categories[]->title
  }
`;

// Pagination
export const paginatedPostsQuery = groq`
  *[_type == "post"] | order(publishedAt desc) [$start...$end] {
    _id,
    title,
    slug,
    excerpt
  }
`;
高度な GROQ
// Conditional projections
export const conditionalQuery = groq`
  *[_type == "post"] {
    title,
    "content": select(
      defined(body) => body,
      "No content available"
    )
  }
`;

// Coalesce for fallbacks
export const fallbackQuery = groq`
  *[_type == "post"] {
    "displayTitle": coalesce(seoTitle, title)
  }
`;

// References
export const withReferencesQuery = groq`
  *[_type == "post" && references($authorId)] {
    title,
    publishedAt
  }
`;

クライアントの設定

// lib/client.ts
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';

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',
});

const builder = imageUrlBuilder(client);

export function urlFor(source: any) {
  return builder.image(source);
}

TypeScript 統合

// Generate types from schema
import { Post, Author } from '@/sanity/types';

export async function getPosts(): Promise<Post[]> {
  return client.fetch(allPostsQuery);
}

export async function getPost(slug: string): Promise<Post | null> {
  return client.fetch(postBySlugQuery, { slug });
}

バリデーション

defineField({
  name: 'email',
  type: 'string',
  validation: (Rule) =>
    Rule.required()
      .email()
      .custom((email) => {
        if (email && !email.endsWith('@company.com')) {
          return 'Must be a company email';
        }
        return true;
      }),
});

カスタムコンポーネント

// Custom input component
import { StringInputProps } from 'sanity';

export function CustomStringInput(props: StringInputProps) {
  return (
    <div>
      <label>{props.schemaType.title}</label>
      {props.renderDefault(props)}
      <span>{props.value?.length ?? 0} characters</span>
    </div>
  );
}

ベストプラクティス

  • ドキュメント間のリレーションシップには参照を使用する
  • 適切なバリデーションルールを実装する
  • 意味のあるプレビュー設定を作成する
  • リッチコンテンツにはポータブルテキストを使用する
  • Sanity の画像パイプラインで画像を最適化する
  • 適切な CORS と API 権限を設定する

コメント