diff --git a/.agents/skills/content-modeling-best-practices/SKILL.md b/.agents/skills/content-modeling-best-practices/SKILL.md new file mode 100644 index 000000000..98d5a7cf1 --- /dev/null +++ b/.agents/skills/content-modeling-best-practices/SKILL.md @@ -0,0 +1,32 @@ +--- +name: content-modeling-best-practices +description: Structured content modeling guidance for schema design, content architecture, content reuse, references versus embedded objects, separation of concerns, and taxonomies across Sanity and other headless CMSes. Use this skill when designing or refactoring content types, deciding field shapes, debating reusable versus nested content, planning omnichannel content models, or reviewing whether a schema is too page-shaped or presentation-driven. +--- + +# Content Modeling Best Practices + +Principles for designing structured content that's flexible, reusable, and maintainable. These concepts apply to any headless CMS but include Sanity-specific implementation notes. + +## When to Apply + +Reference these guidelines when: +- Starting a new project and designing the content model +- Evaluating whether content should be structured or free-form +- Deciding between references and embedded content +- Planning for multi-channel content delivery +- Refactoring existing content structures + +## Core Principles + +1. **Content is data, not pages** — Structure content for meaning, not presentation +2. **Single source of truth** — Avoid content duplication +3. **Future-proof** — Design for channels that don't exist yet +4. **Editor-centric** — Optimize for the people creating content + +## References + +Start with the reference that matches the modeling decision in front of you, instead of loading every topic at once. See `references/` for detailed guidance on specific topics: +- `references/separation-of-concerns.md` — Separating content from presentation +- `references/reference-vs-embedding.md` — When to use references vs embedded objects +- `references/content-reuse.md` — Content reuse patterns and the reuse spectrum +- `references/taxonomy-classification.md` — Flat, hierarchical, and faceted classification diff --git a/.agents/skills/content-modeling-best-practices/references/content-reuse.md b/.agents/skills/content-modeling-best-practices/references/content-reuse.md new file mode 100644 index 000000000..2f31e0a2d --- /dev/null +++ b/.agents/skills/content-modeling-best-practices/references/content-reuse.md @@ -0,0 +1,134 @@ +# Content Reuse Patterns + +Effective content models maximize reuse while minimizing duplication. Here are patterns for achieving both. + +## The Content Reuse Spectrum + +``` +Full Duplication ←————————————————→ Full Reference +(Copy everything) (Link to one source) +``` + +Most real-world content sits somewhere in between. + +## Pattern 1: Shared Components + +Create reusable content blocks that can be embedded anywhere. + +**Use case:** Testimonials, FAQs, CTAs that appear on multiple pages. + +```typescript +// Standalone testimonial documents +defineType({ + name: 'testimonial', + type: 'document', + fields: [ + defineField({ name: 'quote', type: 'text' }), + defineField({ name: 'author', type: 'string' }), + defineField({ name: 'company', type: 'string' }), + ] +}) + +// Reference in page builders +defineField({ + name: 'pageBuilder', + type: 'array', + of: [ + { type: 'reference', to: [{ type: 'testimonial' }] } + ] +}) +``` + +## Pattern 2: Shared Field Sets + +Extract common fields into reusable definitions. + +**Use case:** SEO fields, social metadata, common dates. + +```typescript +// Shared field definition +export const seoFields = [ + defineField({ name: 'seoTitle', type: 'string' }), + defineField({ name: 'seoDescription', type: 'text' }), + defineField({ name: 'ogImage', type: 'image' }), +] + +// Spread into multiple types +defineType({ + name: 'page', + fields: [ + defineField({ name: 'title', type: 'string' }), + ...seoFields + ] +}) + +defineType({ + name: 'post', + fields: [ + defineField({ name: 'title', type: 'string' }), + ...seoFields + ] +}) +``` + +## Pattern 3: Taxonomy References + +Centralize classification for consistent tagging. + +**Use case:** Categories, tags, topics that span content types. + +```typescript +// Central taxonomy +defineType({ + name: 'category', + type: 'document', + fields: [ + defineField({ name: 'title', type: 'string' }), + defineField({ name: 'slug', type: 'slug' }), + ] +}) + +// Used across content types +defineField({ + name: 'categories', + type: 'array', + of: [{ type: 'reference', to: [{ type: 'category' }] }] +}) +``` + +## Pattern 4: Content Fragments + +Small, reusable pieces that combine into larger content. + +**Use case:** Bios, addresses, contact info. + +```typescript +// Fragment type +defineType({ + name: 'contactInfo', + type: 'object', + fields: [ + defineField({ name: 'email', type: 'email' }), + defineField({ name: 'phone', type: 'string' }), + defineField({ name: 'address', type: 'text' }), + ] +}) + +// Reused across types +defineType({ + name: 'office', + fields: [ + defineField({ name: 'name', type: 'string' }), + defineField({ name: 'contact', type: 'contactInfo' }), + ] +}) +``` + +## Anti-Pattern: Over-Abstraction + +Not everything needs to be reusable. If content is only used in one place, embedding is simpler. + +**Signs of over-abstraction:** +- References that are only used once +- Editors navigating multiple documents for one page +- Complex queries joining rarely-shared content diff --git a/.agents/skills/content-modeling-best-practices/references/reference-vs-embedding.md b/.agents/skills/content-modeling-best-practices/references/reference-vs-embedding.md new file mode 100644 index 000000000..52098a531 --- /dev/null +++ b/.agents/skills/content-modeling-best-practices/references/reference-vs-embedding.md @@ -0,0 +1,89 @@ +# Reference vs Embedding Content + +When should content be linked (referenced) vs copied (embedded)? This decision affects reusability, query complexity, and editing workflows. + +## The Trade-offs + +| Aspect | Reference | Embedded Object | +|--------|-----------|-----------------| +| Reusability | ✅ Shared across documents | ❌ Copied per document | +| Single source | ✅ Update once, reflects everywhere | ❌ Must update each copy | +| Query complexity | Requires joins/expansion | Inline, simpler queries | +| Editing UX | Separate editing interface | All fields in one place | +| Independence | Can exist on its own | Only exists within parent | + +## When to Reference + +Use references when content: +- **Is reusable** — Same author across many articles +- **Needs central management** — Update product info once +- **Has its own lifecycle** — Published/draft independent of parent +- **Should stay in sync** — Price changes reflect everywhere + +**Examples:** +- Author profiles +- Product catalog items +- Shared testimonials +- Category taxonomy +- Reusable CTAs + +## When to Embed + +Use embedded objects when content: +- **Is unique to this document** — Page-specific hero +- **Doesn't make sense alone** — SEO metadata +- **Should be copied, not linked** — Historical snapshot +- **Simplifies editing** — All fields in one form + +**Examples:** +- SEO metadata +- Page-specific sections +- Address information +- Social links +- Configuration options + +## Sanity Implementation + +```typescript +// Reference: Author is reusable +defineField({ + name: 'author', + type: 'reference', + to: [{ type: 'author' }] +}) + +// Embedded: SEO is page-specific +defineField({ + name: 'seo', + type: 'object', + fields: [ + defineField({ name: 'title', type: 'string' }), + defineField({ name: 'description', type: 'text' }) + ] +}) +``` + +## The Hybrid Approach + +Sometimes you want both: a reference for the canonical data, plus embedded overrides. + +```typescript +defineField({ + name: 'featuredProduct', + type: 'object', + fields: [ + defineField({ + name: 'product', + type: 'reference', + to: [{ type: 'product' }] + }), + defineField({ + name: 'overrideTitle', + type: 'string', + description: 'Optional: Override the product title for this context' + }), + ] +}) +``` + +Query uses `coalesce(overrideTitle, product->title)`. diff --git a/.agents/skills/content-modeling-best-practices/references/separation-of-concerns.md b/.agents/skills/content-modeling-best-practices/references/separation-of-concerns.md new file mode 100644 index 000000000..5ce2085a5 --- /dev/null +++ b/.agents/skills/content-modeling-best-practices/references/separation-of-concerns.md @@ -0,0 +1,60 @@ +# Separation of Content and Presentation + +The most important principle in structured content: **separate what content IS from how it LOOKS**. + +## The Problem + +When content is tied to presentation: +- Redesigns require content migration +- Content can't be reused across channels (web, mobile, voice) +- Editors make design decisions instead of content decisions +- A/B testing requires duplicate content + +## The Principle + +Model content based on **meaning and purpose**, not visual appearance. + +### Bad: Presentation-Focused + +``` +BigHeroText → What if we want small heroes? +RedButton → What if brand colors change? +ThreeColumnLayout → What if mobile needs one column? +LeftSidebar → Position is a frontend concern +MobileImage → Device-specific content is fragile +``` + +### Good: Meaning-Focused + +``` +Headline → The main message (render however) +CallToAction → An action we want users to take +Features → A list of things (columns decided by frontend) +RelatedContent → Content relationships (position by context) +Image → One image with responsive crops +``` + +## Testing Your Model + +Ask: "If we completely redesigned the site, would these field names still make sense?" + +- `threeColumnFeatures` → ❌ Fails (what if 2 columns?) +- `features` → ✅ Works (describes the content's purpose: a list of product features) +- `blueHighlightBox` → ❌ Fails (what if we go purple?) +- `callout` → ✅ Works (describes the content's role: an attention-grabbing aside) + +## Sanity Implementation + +```typescript +// ❌ Avoid presentation-focused names +defineField({ name: 'bigHeroText', type: 'string' }) +defineField({ name: 'fontSize', type: 'number' }) +defineField({ name: 'backgroundColor', type: 'color' }) + +// ✅ Use meaning-focused names +defineField({ name: 'headline', type: 'string' }) +defineField({ name: 'emphasis', type: 'string', options: { list: ['standard', 'prominent'] } }) +defineField({ name: 'tone', type: 'string', options: { list: ['neutral', 'warning', 'success'] } }) +``` + +The frontend translates `tone: 'warning'` to visual styles. Content stays semantic. diff --git a/.agents/skills/content-modeling-best-practices/references/taxonomy-classification.md b/.agents/skills/content-modeling-best-practices/references/taxonomy-classification.md new file mode 100644 index 000000000..d8c077d0e --- /dev/null +++ b/.agents/skills/content-modeling-best-practices/references/taxonomy-classification.md @@ -0,0 +1,136 @@ +# Taxonomy and Classification + +Organizing content with taxonomies enables filtering, navigation, and content relationships. Well-designed taxonomies scale; poorly designed ones become maintenance nightmares. + +## Types of Classification + +### Flat Taxonomy +Simple list of terms with no hierarchy. + +**Use for:** Tags, simple categories +**Example:** Blog tags: "javascript", "react", "tutorial" + +```typescript +defineType({ + name: 'tag', + type: 'document', + fields: [ + defineField({ name: 'title', type: 'string' }), + defineField({ name: 'slug', type: 'slug' }), + ] +}) +``` + +### Hierarchical Taxonomy +Terms with parent-child relationships. + +**Use for:** Product categories, content sections +**Example:** Electronics > Phones > Smartphones + +```typescript +defineType({ + name: 'category', + type: 'document', + fields: [ + defineField({ name: 'title', type: 'string' }), + defineField({ name: 'slug', type: 'slug' }), + defineField({ + name: 'parent', + type: 'reference', + to: [{ type: 'category' }], + description: 'Parent category (leave empty for top-level)' + }), + ] +}) +``` + +### Faceted Classification +Multiple independent dimensions. + +**Use for:** Complex filtering (e-commerce) +**Example:** Filter by color AND size AND price range + +```typescript +// Multiple taxonomy types +defineField({ name: 'color', type: 'reference', to: [{ type: 'color' }] }) +defineField({ name: 'size', type: 'reference', to: [{ type: 'size' }] }) +defineField({ name: 'material', type: 'reference', to: [{ type: 'material' }] }) +``` + +## Design Principles + +### 1. Mutual Exclusivity (When Appropriate) +Categories should be distinct. If items frequently belong to multiple categories, consider tags instead. + +**Categories:** One primary classification +**Tags:** Many optional classifications + +### 2. User-Centric Naming +Use terms your audience uses, not internal jargon. + +**Bad:** "Content Assets" (internal term) +**Good:** "Resources" or "Downloads" (user term) + +### 3. Balanced Depth +Too shallow: Everything lumped together +Too deep: Users can't find anything + +**Rule of thumb:** 3-4 levels max for hierarchies + +### 4. Scalable Structure +Design for 10x growth. Will your structure work with 10,000 items? + +## Querying Taxonomies + +### Get all items in a category + +```groq +*[_type == "product" && category._ref == $categoryId] +``` + +### Get items in category OR children + +```groq +// First get all descendant category IDs +*[_type == "product" && category._ref in + *[_type == "category" && ( + _id == $categoryId || + parent._ref == $categoryId || + parent->parent._ref == $categoryId + )]._id +] +``` + +### Get category tree + +```groq +*[_type == "category" && !defined(parent)]{ + title, + slug, + "children": *[_type == "category" && parent._ref == ^._id]{ + title, + slug, + "children": *[_type == "category" && parent._ref == ^._id]{ + title, + slug + } + } +} +``` + +## Common Mistakes + +### Over-categorization +Creating a category for everything results in mostly-empty categories. + +**Fix:** Start minimal, add categories as content grows. + +### Inconsistent Granularity +Some categories broad ("Technology"), others narrow ("React 18 Server Components"). + +**Fix:** Define clear criteria for category creation. + +### No Governance +Anyone can create taxonomy terms, leading to duplicates and inconsistency. + +**Fix:** Limit who can create/edit taxonomy documents. Use validation. diff --git a/.agents/skills/portable-text-conversion/SKILL.md b/.agents/skills/portable-text-conversion/SKILL.md new file mode 100644 index 000000000..996587345 --- /dev/null +++ b/.agents/skills/portable-text-conversion/SKILL.md @@ -0,0 +1,65 @@ +--- +name: portable-text-conversion +description: Convert HTML and Markdown content into Portable Text blocks for Sanity. Use when migrating content from legacy CMSs, importing HTML or Markdown into Sanity, building content pipelines that ingest external content, converting rich text between formats, or programmatically creating Portable Text documents. Covers @portabletext/markdown (markdownToPortableText), @portabletext/block-tools (htmlToBlocks), custom deserializers, and the Portable Text specification for manual block construction. +license: MIT +metadata: + author: sanity + version: "1.0.0" +--- + +# Portable Text Conversion + +Convert external content (HTML, Markdown) into Portable Text for Sanity. Three main approaches: + +1. **`markdownToPortableText`** — Convert Markdown directly using `@portabletext/markdown` (recommended for Markdown) +2. **`htmlToBlocks`** — Parse HTML into PT blocks using `@portabletext/block-tools` (for HTML migration) +3. **Manual construction** — Build PT blocks directly from any source (APIs, databases, etc.) + +## Portable Text Specification + +Understand the target format before converting. PT is an array of blocks: + +```json +[ + { + "_type": "block", + "_key": "abc123", + "style": "normal", + "children": [ + {"_type": "span", "_key": "def456", "text": "Hello ", "marks": []}, + {"_type": "span", "_key": "ghi789", "text": "world", "marks": ["strong"]} + ], + "markDefs": [] + }, + { + "_type": "block", + "_key": "jkl012", + "style": "h2", + "children": [ + {"_type": "span", "_key": "mno345", "text": "A heading", "marks": []} + ], + "markDefs": [] + }, + { + "_type": "image", + "_key": "pqr678", + "asset": {"_type": "reference", "_ref": "image-abc-200x200-png"} + } +] +``` + +**Key rules:** +- Every block and span needs `_key` (unique within the array) +- `_type: "block"` is for text blocks; custom types use their own `_type` +- `markDefs` holds annotation data; `marks` on spans reference `markDefs[*]._key` or are decorator strings +- Lists use `listItem` ("bullet" | "number") and `level` (1, 2, 3...) on regular blocks + +## Conversion Rules + +Read the rule file matching your source format: + +- **Markdown → Portable Text**: `rules/markdown-to-pt.md` — `@portabletext/markdown` with `markdownToPortableText` (recommended) +- **HTML → Portable Text**: `rules/html-to-pt.md` — `@portabletext/block-tools` with `htmlToBlocks` +- **Manual PT Construction**: `rules/manual-construction.md` — build blocks programmatically from any source + +> **Note:** `@sanity/block-tools` is the legacy package name. Always use `@portabletext/block-tools` for new projects. The API is the same. diff --git a/.agents/skills/portable-text-conversion/rules/html-to-pt.md b/.agents/skills/portable-text-conversion/rules/html-to-pt.md new file mode 100644 index 000000000..153e88b70 --- /dev/null +++ b/.agents/skills/portable-text-conversion/rules/html-to-pt.md @@ -0,0 +1,242 @@ +--- +title: Convert HTML to Portable Text +description: Use @portabletext/block-tools with htmlToBlocks to convert HTML content into Portable Text blocks +tags: [portable-text, html, conversion, migration, import] +--- + +# Convert HTML to Portable Text + +Use `@portabletext/block-tools` to parse HTML into Portable Text blocks. This is the primary tool for migrating HTML content from legacy CMSs. It has built-in support for content from Google Docs, Microsoft Word, and Notion. + +> **Note:** For Markdown sources, use `@portabletext/markdown` instead — it's simpler and more direct. See `rules/markdown-to-pt.md`. + +> **Note:** `@sanity/block-tools` is the legacy package name. Use `@portabletext/block-tools` for new projects. The API is identical. + +## Setup + +```bash +npm install @portabletext/block-tools jsdom @sanity/schema +``` + +In Node.js, you must provide a `parseHtml` function that returns a DOM `Document`. Use JSDOM for this: + +```ts +import {htmlToBlocks} from '@portabletext/block-tools' +import {JSDOM} from 'jsdom' +import Schema from '@sanity/schema' + +// JSDOM is passed to htmlToBlocks via the parseHtml option: +// htmlToBlocks(html, blockContentType, { +// parseHtml: (html) => new JSDOM(html).window.document, +// }) +``` + +## Define Your Schema + +`htmlToBlocks` needs a compiled Sanity block content type to know which marks, styles, and custom types are valid. Use `@sanity/schema` to compile it: + +```ts +const defaultSchema = Schema.compile({ + name: 'mySchema', + types: [ + { + name: 'post', + type: 'document', + fields: [ + { + name: 'body', + type: 'array', + of: [ + { + type: 'block', + marks: { + decorators: [ + {title: 'Strong', value: 'strong'}, + {title: 'Emphasis', value: 'em'}, + {title: 'Code', value: 'code'}, + ], + annotations: [ + { + name: 'link', + type: 'object', + fields: [{name: 'href', type: 'url'}], + }, + ], + }, + styles: [ + {title: 'Normal', value: 'normal'}, + {title: 'H2', value: 'h2'}, + {title: 'H3', value: 'h3'}, + {title: 'Quote', value: 'blockquote'}, + ], + lists: [ + {title: 'Bullet', value: 'bullet'}, + {title: 'Number', value: 'number'}, + ], + }, + { + name: 'image', + type: 'image', + fields: [{name: 'alt', type: 'string'}], + }, + ], + }, + ], + }, + ], +}) + +const blockContentType = defaultSchema + .get('post') + .fields.find((f) => f.name === 'body').type +``` + +## Basic Conversion + +```ts +const html = '

Hello world

Heading

' + +const blocks = htmlToBlocks(html, blockContentType, { + parseHtml: (html) => new JSDOM(html).window.document, +}) +``` + +## Custom Deserializers + +Handle HTML elements that don't map directly to standard PT: + +```ts +const blocks = htmlToBlocks(html, blockContentType, { + parseHtml: (html) => new JSDOM(html).window.document, + rules: [ + // Convert to image blocks + { + deserialize(el, next, block) { + if (el.tagName?.toLowerCase() !== 'img') return undefined + + return block({ + _type: 'image', + asset: { + _type: 'reference', + _ref: '', // Upload image separately, set ref after + }, + alt: el.getAttribute('alt') || '', + _sanityAsset: `image@${el.getAttribute('src')}`, // for migration tooling + }) + }, + }, + // Convert with custom attributes + { + deserialize(el, next, block) { + if (el.tagName?.toLowerCase() !== 'a') return undefined + + const href = el.getAttribute('href') || '' + const target = el.getAttribute('target') || '' + + return { + _type: '__annotation', + markDef: { + _type: 'link', + href, + ...(target ? {target} : {}), + }, + children: next(el.childNodes), + } + }, + }, + // Convert