# ⚡️ Gearbox Sanity Schema Tool

A fully-typed schema builder for Sanity Studio V3/V4 that provides thoughtful defaults, succinct syntax, and powerful helpers to make writing schemas faster and more maintainable.

**Built on top of Sanity's `defineField` functions** to benefit from their improved developer experience while adding helpful abstractions.

## Features

- 🎯 **Shorthand Syntax** - Write schemas faster with functions like `F.slug()`, `F.title()`, `F.string({ name: 'foo' })`
- 🧩 **Custom Fields** - Pre-built composite fields for common patterns (hero, link, media, seo)
- 📦 **Smart Defaults** - Sensible defaults that follow Sanity best practices
- 🎨 **Group & Fieldset Helpers** - Organize complex schemas with simple, readable syntax
- ⚙️ **Configurable** - Set global defaults for field types across your entire project
- 🔌 **Extensible** - Add custom field types that integrate seamlessly with the tool
- 📘 **Fully Typed** - Complete TypeScript support with inference and autocomplete

## Installation

```sh
# yarn
yarn add @gearbox-built/sanity-schema-tool

# npm
npm install @gearbox-built/sanity-schema-tool

# pnpm
pnpm add @gearbox-built/sanity-schema-tool
```

## Quick Start

Import the schema tool and start building schemas:

```ts
import {F} from '@gearbox-built/sanity-schema-tool'

export const movie = F.document({
  name: 'movie',
  fields: [
    F.title(),
    F.slug(),
    F.image({name: 'poster'}),
    F.text({name: 'synopsis'}),
    F.array({name: 'cast', of: [F.string()]}),
    F.publishedDate(),
  ],
})
```

**That's equivalent to:**

```ts
export const movie = {
  type: 'document',
  name: 'movie',
  title: 'Movie',
  fields: [
    {
      type: 'string',
      name: 'title',
      title: 'Title',
    },
    {
      type: 'slug',
      name: 'slug',
      title: 'Slug',
      required: true,
      description: 'Leave blank to autofill.',
      options: {
        source: 'title',
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    },
    {
      type: 'image',
      name: 'poster',
      title: 'Poster',
    },
    {
      type: 'text',
      name: 'synopsis',
      title: 'Synopsis',
    },
    {
      type: 'array',
      name: 'cast',
      title: 'Cast',
      of: [{type: 'string'}],
    },
    {
      type: 'date',
      name: 'publishedDate',
      title: 'Published Date',
      required: true,
      options: {
        dateFormat: 'MMMM DD, YYYY',
      },
      initialValue: () => new Date().toISOString().split('T')[0],
      validation: (Rule) => Rule.required(),
    },
  ],
}
```

## Core Concepts

The schema tool exports five main namespaces:

- **`F`** - Fields (all Sanity field types + custom composite fields)
- **`G`** - Groups (tab organization helpers)
- **`P`** - Preview (preview configuration helpers)
- **`V`** - Validation (validation helpers)
- **`FS`** - Fieldsets (fieldset helpers)

### Auto-generated Names and Titles

Fields automatically generate names and titles using lodash utilities:

- **Names**: `camelCase` of the title (if title provided) or explicit name
- **Titles**: `startCase` of the name (if name provided) or explicit title

```ts
// These are equivalent:
F.string({name: 'firstName'}) // title becomes "First Name"
F.string({title: 'First Name'}) // name becomes "firstName"
F.string({name: 'firstName', title: 'Given Name'}) // uses both as specified
```

### Required Fields

The `required` boolean prop is a shorthand that automatically adds validation:

```ts
F.string({name: 'email', required: true})
// Equivalent to:
F.string({
  name: 'email',
  validation: (Rule) => Rule.required(),
})
```

## API Reference

### F - Fields

#### Core Field Types

All standard Sanity field types are available:

```ts
// String-based types
F.string(props) // Basic string
F.text(props) // Multi-line text
F.email(props) // Email validation
F.url(props) // URL validation
F.slug(props) // Slug with smart defaults

// Number and date types
F.number(props) // Number input
F.date(props) // Date picker
F.datetime(props) // Date and time picker

// Media types
F.image(props) // Image upload
F.file(props) // File upload

// Structural types
F.boolean(props) // Boolean toggle
F.object(props) // Object with nested fields
F.array(props) // Array of items
F.reference(types, props) // Reference to other documents
F.block(props) // Block content
F.geopoint(props) // Geographic coordinates

// Document type
F.document(props) // Document definition
```

**Example:**

```ts
const product = F.document({
  name: 'product',
  fields: [
    F.string({name: 'sku', required: true}),
    F.number({name: 'price', validation: (Rule) => Rule.min(0)}),
    F.boolean({name: 'inStock', initialValue: true}),
    F.geopoint({name: 'location'}),
  ],
})
```

#### Custom Composite Fields

Pre-built fields for common patterns:

```ts
F.title(props) // Title field with defaults
F.excerpt(props) // Excerpt/summary field
F.publishedDate(props) // Published date with formatting
F.checkbox(props) // Boolean as checkbox
F.radio(list, props) // Radio button group
F.dropdown(list, props) // Dropdown select
F.category(types, props) // Single category reference
F.categories(types, props) // Multiple categories
F.multiReference(types, props) // Multiple references
F.blockContent(props) // Rich text content
F.message(text, props) // Display-only message
F.formField(props) // Form field configuration
```

**Radio & Dropdown:**

```ts
F.radio(['small', 'medium', 'large'], {name: 'size'})
F.dropdown(['draft', 'published', 'archived'], {name: 'status'})

// With custom labels:
F.radio(
  [
    {title: 'Small (SM)', value: 'small'},
    {title: 'Large (LG)', value: 'large'},
  ],
  {name: 'size'},
)
```

**References:**

```ts
// Single category
F.category('productCategory', {name: 'category'})

// Multiple categories
F.categories(['category', 'tag'], {name: 'categories'})

// Custom multi-reference
F.multiReference(['post', 'page'], {
  name: 'relatedContent',
  title: 'Related Content',
})
```

#### Advanced Composite Fields

Complex fields with conditional sub-fields:

##### Hero Field

```ts
F.hero(props)

// With custom configuration:
F.hero({
  name: 'hero',
  args: {
    label: false, // Disable label field
    heading: {name: 'heading', required: true},
    content: {name: 'content'},
    link: {
      name: 'cta',
      conditions: {none: []}, // Add 'none' option
    },
    media: {
      args: {caption: false}, // Disable caption in media
    },
    align: {initialValue: 'center'},
  },
})
```

The hero field includes:

- `label` - Optional eyebrow label
- `heading` - Main heading text
- `content` - Rich text content (blockContentSimple)
- `link` - Call-to-action link
- `media` - Image or video
- `align` - Content alignment (left/center/right)

##### Link Field

```ts
F.link(props)

// Basic link with defaults:
F.link({name: 'cta'})

// Custom configuration:
F.link({
  name: 'mainLink',
  types: ['page', 'post', 'product'], // Document types for internal links
  conditions: {
    none: [], // Add 'none' option
    internal: [], // Internal page links (default)
    external: [], // External URLs (default)
    download: [], // File downloads (default)
    video: [], // Video links
  },
  args: {
    label: {name: 'label', required: true},
    linkStyle: false, // Disable style options
    target: true, // Show "open in new tab" option
  },
})
```

The link field includes conditional sub-fields based on link type:

- **Internal**: page reference, hash anchor options
- **External**: URL input, target (new tab) option
- **Download**: file upload
- **Video**: video file upload
- **Common**: label, link style (text/button/ghost), size

##### Media Field

```ts
F.media(props)

// Basic media:
F.media({name: 'heroMedia'})

// Custom configuration:
F.media({
  name: 'featuredMedia',
  conditions: {
    none: [], // Add 'none' option
    image: [], // Image upload (default)
    video: [], // Video upload (default)
    lottie: [], // Lottie animation
  },
  args: {
    caption: {name: 'caption', hidden: ({parent}) => !parent?.image},
    ratio: {name: 'aspectRatio', initialValue: '16:9'},
  },
})
```

The media field includes:

- Condition selector (image/video)
- Conditional image or video fields
- Optional caption
- Optional aspect ratio

##### SEO Field

```ts
F.seo(props)

// Basic SEO field:
F.seo() // Uses 'seo' as name

// Custom name:
F.seo({name: 'pageSeo', title: 'Page SEO'})
```

Note: The SEO field currently expects a custom 'seo' type to be defined in your Sanity schema. It's an alias field that references your project's SEO object type.

##### Content Group

Returns an array of fields (not a single object field):

```ts
F.contentGroup(props)

// Basic usage:
const fields = F.contentGroup()

// Custom configuration:
const fields = F.contentGroup({
  label: {name: 'eyebrow'},
  heading: {name: 'title', required: true},
  content: false, // Disable content field
  link: {name: 'cta'},
})

// Use in document:
F.document({
  name: 'section',
  fields: [
    ...F.contentGroup(), // Spreads label, heading, content, link fields
    F.image({name: 'background'}),
  ],
})
```

#### Utility Functions

```ts
F.field(type, props) // Generic field creator
F.apply(props, fields) // Apply props to multiple fields
```

**Example:**

```ts
// Apply common props to multiple fields:
const requiredFields = F.apply({required: true, group: 'contact'}, [
  F.string({name: 'email'}),
  F.string({name: 'phone'}),
])
```

### G - Groups

Groups organize fields into tabs in the Sanity Studio:

```ts
G.define(name, props) // Define a group
G.group(name, fields) // Assign fields to a group
G.content() // Predefined 'content' group
G.meta() // Predefined 'meta' group
G.options() // Predefined 'options' group
```

**Example:**

```ts
import {AiOutlineHome as icon} from 'react-icons/ai'
import {F, G} from '@gearbox-built/sanity-schema-tool'

export const page = F.document({
  icon,
  name: 'page',
  // Define the tabs:
  groups: [G.define('content'), G.define('meta'), G.define('seo', {title: 'SEO'})],
  // Assign fields to tabs:
  fields: [
    ...G.group('content', [F.title(), F.field('hero'), F.field('components')]),
    ...G.group('meta', [F.slug(), F.field('path'), F.publishedDate()]),
    ...G.group('seo', [F.seo()]),
  ],
})
```

**Predefined Groups:**

```ts
// These are pre-configured groups you can use:
groups: [G.content(), G.meta(), G.options()]

// Equivalent to:
groups: [
  {name: 'content', title: 'Content', default: true},
  {name: 'meta', title: 'Meta'},
  {name: 'options', title: 'Options'},
]
```

### P - Preview

Preview configuration helpers:

```ts
P.preview(props) // Generic preview config
P.titleImage(props) // Title + image preview (default)
P.text(title) // Text-only preview
P.richText(blocks) // Convert rich text to preview
P.link(props) // Link preview helper
P.contentGroup(props) // Content group preview
```

**Example:**

```ts
export const article = F.document({
  name: 'article',
  fields: [F.title(), F.image({name: 'cover'}), F.publishedDate()],

  // Simple preview:
  preview: P.titleImage(), // Uses 'title' and 'image'

  // Custom preview:
  preview: P.preview({
    title: 'title',
    media: 'cover',
    subtitle: 'publishedDate',
  }),

  // Custom prepare function:
  preview: {
    select: {
      title: 'title',
      date: 'publishedDate',
    },
    prepare: ({title, date}) => ({
      title: title || 'Untitled',
      subtitle: date ? new Date(date).toLocaleDateString() : 'No date',
    }),
  },
})
```

**Text-only preview:**

```ts
export const category = F.document({
  name: 'category',
  fields: [F.title()],
  preview: P.text('title'),
})
```

### V - Validation

Validation helpers:

```ts
V.required // Required field validation
V.validation(customValidator) // Custom validation function
```

**Example:**

```ts
import {V} from '@gearbox-built/sanity-schema-tool'

F.string({
  name: 'email',
  validation: V.required, // Note: this is a function, not a call
})

// Custom validation:
F.string({
  name: 'username',
  validation: V.validation((value) => {
    if (!value) return true
    return value.length >= 3 || 'Username must be at least 3 characters'
  }),
})

// Multiple validations:
F.string({
  name: 'password',
  validation: (Rule) => Rule.required().min(8).max(100),
})
```

### FS - Fieldsets

Fieldset helpers for grouping related fields:

```ts
FS.define(name, props) // Define a fieldset
FS.fieldset(name, fields) // Assign fields to a fieldset
FS.seo() // Predefined SEO fieldset
```

**Example:**

```ts
export const product = F.document({
  name: 'product',
  fieldsets: [
    FS.define('pricing', {
      title: 'Pricing',
      options: {collapsible: true, collapsed: false},
    }),
  ],
  fields: [
    F.title(),
    ...FS.fieldset('pricing', [
      F.number({name: 'price'}),
      F.number({name: 'salePrice'}),
      F.boolean({name: 'onSale'}),
    ]),
  ],
})
```

## Configuration

Use `withConfig()` to set global defaults for field types across your project.

### Basic Configuration

Create a configuration file:

```ts
// lib/schemaTool.ts
import {F as _F, withConfig} from '@gearbox-built/sanity-schema-tool'

export const {F, FS, G, P, V} = withConfig({
  // Set defaults for image fields:
  image: {
    fields: [_F.string({name: 'alt', title: 'Alt Text'}), _F.string({name: 'caption'})],
  },

  // Make slugs optional by default:
  slug: {
    required: false,
  },

  // Add default validation to strings:
  string: {
    validation: (Rule) => Rule.max(200),
  },

  // Set default for published dates:
  publishedDate: {
    options: {
      dateFormat: 'YYYY-MM-DD',
    },
  },
})

// Re-export types:
export type * from '@gearbox-built/sanity-schema-tool'
```

Then use your configured schema tool:

```ts
// schemas/product.ts
import {F} from '@/lib/schemaTool'

export const product = F.document({
  name: 'product',
  fields: [
    F.title(),
    F.slug(), // Automatically optional (from config)
    F.image({name: 'photo'}), // Automatically includes alt and caption
  ],
})
```

### All Configurable Fields

You can configure defaults for these field types:

```ts
withConfig({
  // Core types:
  array: {},
  block: {},
  blockContent: {},
  boolean: {},
  date: {},
  datetime: {},
  document: {},
  email: {},
  file: {},
  geopoint: {},
  image: {},
  number: {},
  object: {},
  reference: {},
  slug: {},
  string: {},
  text: {},
  url: {},

  // Custom types:
  categories: {},
  category: {},
  checkbox: {},
  contentGroup: {},
  excerpt: {},
  formField: {},
  hero: {},
  link: {},
  media: {},
  message: {},
  multiReference: {},
  publishedDate: {},
  radio: {},
  seo: {},
  title: {},
})
```

## Extension

Extend the schema tool with custom field types using the second parameter of `withConfig()`.

### Adding Custom Fields

```ts
// lib/schemaTool.ts
import {
  F as _F,
  withConfig,
  type StringField,
  type FieldReturn,
  type RadioList,
} from '@gearbox-built/sanity-schema-tool'

// Define custom types:
export const customTypes = {
  F: {
    // Custom dropdown field:
    dropdown: (list: RadioList, props?: StringField): FieldReturn =>
      _F.string({
        ...props,
        options: {
          list: list.map((item) =>
            typeof item === 'string' ? {title: item, value: item.toLowerCase()} : item,
          ),
          layout: 'dropdown',
        },
      }),

    // Custom phone field:
    phone: (props?: StringField): FieldReturn =>
      _F.string({
        ...props,
        name: props?.name || 'phone',
        validation: (Rule) =>
          Rule.regex(
            /^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/,
            'Please enter a valid phone number',
          ),
      }),

    // Custom color picker:
    color: (props?: StringField): FieldReturn =>
      _F.string({
        ...props,
        name: props?.name || 'color',
        options: {
          list: [
            {title: 'Red', value: '#ff0000'},
            {title: 'Blue', value: '#0000ff'},
            {title: 'Green', value: '#00ff00'},
          ],
        },
      }),
  },
}

// Export configured schema tool with custom types:
export const {F, FS, G, P, V} = withConfig(
  {
    // Your config here
    slug: {required: false},
  },
  customTypes,
)

export type * from '@gearbox-built/sanity-schema-tool'
```

### Using Custom Types

```ts
import {F} from '@/lib/schemaTool'

export const contact = F.document({
  name: 'contact',
  fields: [
    F.string({name: 'name'}),
    F.phone(), // Custom phone field
    F.dropdown(['Mr', 'Mrs', 'Ms', 'Dr'], {name: 'title'}), // Custom dropdown
    F.color({name: 'favoriteColor'}), // Custom color picker
  ],
})
```

### Adding Custom Validators

```ts
const customTypes = {
  V: {
    email: () => (Rule: any) => Rule.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format'),

    url: () => (Rule: any) => Rule.uri({scheme: ['http', 'https']}),
  },
}

// Usage:
F.string({
  name: 'email',
  validation: V.email(),
})
```

### Adding Custom Previews

```ts
const customTypes = {
  P: {
    articlePreview: () => ({
      select: {
        title: 'title',
        author: 'author.name',
        date: 'publishedDate',
        image: 'coverImage',
      },
      prepare: ({title, author, date, image}) => ({
        title: title || 'Untitled',
        subtitle: `${author} - ${new Date(date).toLocaleDateString()}`,
        media: image,
      }),
    }),
  },
}
```

## Complete Example

Here's a complete example showing various features:

```ts
// lib/schemaTool.ts
import {F as _F, withConfig} from '@gearbox-built/sanity-schema-tool'

const customTypes = {
  F: {
    dropdown: (list, props) => _F.string({...props, options: {list, layout: 'dropdown'}}),
  },
}

export const {F, FS, G, P, V} = withConfig(
  {
    image: {
      fields: [_F.string({name: 'alt', title: 'Alt Text'})],
    },
    slug: {required: false},
  },
  customTypes,
)

export type * from '@gearbox-built/sanity-schema-tool'
```

```ts
// schemas/blogPost.ts
import {AiOutlineFileText as icon} from 'react-icons/ai'
import {F, G, P} from '@/lib/schemaTool'

export const blogPost = F.document({
  icon,
  name: 'blogPost',
  title: 'Blog Post',
  groups: [G.define('content'), G.define('meta'), G.define('seo', {title: 'SEO'})],
  fields: [
    // Content group:
    ...G.group('content', [
      F.title({required: true}),
      F.excerpt(),
      F.image({name: 'coverImage', title: 'Cover Image'}),
      F.hero({
        name: 'hero',
        args: {
          label: false,
          media: {
            conditions: {
              none: [],
              image: [],
              video: [],
            },
          },
        },
      }),
      F.blockContent({name: 'body'}),
      F.array({
        name: 'tags',
        of: [F.string()],
        options: {layout: 'tags'},
      }),
    ]),

    // Meta group:
    ...G.group('meta', [
      F.slug(),
      F.reference(['author'], {name: 'author', title: 'Author'}),
      F.categories(['category', 'topic'], {name: 'categories'}),
      F.publishedDate(),
      F.dropdown(['draft', 'review', 'published'], {
        name: 'status',
        initialValue: 'draft',
      }),
    ]),

    // SEO group:
    ...G.group('seo', [F.seo()]),
  ],
  preview: P.preview({
    title: 'title',
    subtitle: 'excerpt',
    media: 'coverImage',
  }),
  orderings: [
    {
      title: 'Published Date, New',
      name: 'publishedDateDesc',
      by: [{field: 'publishedDate', direction: 'desc'}],
    },
  ],
})
```

## TypeScript

This package is fully typed. Import types as needed:

```ts
import type {
  FieldReturn,
  FieldProps,
  StringField,
  ImageField,
  ArrayField,
  ConfigType,
  CustomTypes,
} from '@gearbox-built/sanity-schema-tool'
```

## License

[MIT](LICENSE) © Gearbox Development Inc.

## Develop & Test

This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) with default configuration for build & watch scripts.

See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) on how to run this plugin with hotreload in the studio.

### Build Commands

```sh
# Build the package
yarn build

# Type check
yarn ts

# Watch mode for development
yarn watch

# Link and watch for local testing
yarn link-watch

# Linting
yarn lint

# Format code
yarn format

# Run tests
yarn test

# Test with coverage
yarn test:coverage
```
