Настройка Portable Text для Rich Content Sanity
Portable Text — формат хранения rich text в Sanity. Это JSON-структура, не HTML: блоки с типами, маркеры, аннотации, встроенные объекты. Одни и те же данные рендерятся в HTML, React Native, PDF и любой другой формат через соответствующие сериализаторы.
Схема Portable Text
// schemas/blockContent.ts
import { defineArrayMember, defineType } from 'sanity'
export const blockContentType = defineType({
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'H4', value: 'h4' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' },
],
marks: {
decorators: [
{ title: 'Bold', value: 'strong' },
{ title: 'Italic', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' },
],
annotations: [
{
name: 'link',
type: 'object',
fields: [
{ name: 'href', type: 'url', title: 'URL' },
{ name: 'blank', type: 'boolean', title: 'Open in new tab' },
],
},
{
name: 'internalLink',
type: 'object',
fields: [
{ name: 'reference', type: 'reference', to: [{ type: 'post' }, { type: 'page' }] },
],
},
],
},
}),
// Встроенные блоки
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
{ name: 'alt', type: 'string', title: 'Alt text' },
{ name: 'caption', type: 'string', title: 'Caption' },
],
}),
// Кастомный callout блок
defineArrayMember({
type: 'object',
name: 'callout',
title: 'Callout',
icon: () => '💡',
fields: [
{
name: 'type',
type: 'string',
options: { list: [
{ value: 'info', title: 'Info' },
{ value: 'warning', title: 'Warning' },
{ value: 'tip', title: 'Tip' },
]},
initialValue: 'info',
},
{ name: 'text', type: 'text', title: 'Text' },
],
preview: { select: { title: 'text', subtitle: 'type' } },
}),
// Блок кода
defineArrayMember({
type: 'object',
name: 'codeBlock',
title: 'Code',
icon: () => '</>',
fields: [
{ name: 'code', type: 'text', title: 'Code' },
{
name: 'language',
type: 'string',
options: { list: ['typescript', 'javascript', 'python', 'bash', 'sql', 'yaml'] },
initialValue: 'typescript',
},
{ name: 'filename', type: 'string', title: 'Filename' },
],
}),
],
})
Рендеринг через @portabletext/react
npm install @portabletext/react
// components/PortableTextContent.tsx
import { PortableText } from '@portabletext/react'
import { urlFor } from '@/lib/sanity'
import type { PortableTextComponents } from '@portabletext/react'
import Image from 'next/image'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<figure className="my-8">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
width={800}
height={Math.round(800 / (value.asset?.metadata?.dimensions?.aspectRatio || 1.5))}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
),
callout: ({ value }) => (
<div className={`callout callout-${value.type} p-4 rounded-lg my-6 border-l-4`}>
<p>{value.text}</p>
</div>
),
codeBlock: ({ value }) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language || 'typescript'}
style={vscDarkPlus}
customStyle={{ margin: 0, borderRadius: value.filename ? '0 0 8px 8px' : '8px' }}
>
{value.code}
</SyntaxHighlighter>
</div>
),
},
marks: {
link: ({ value, children }) => (
<a
href={value?.href}
target={value?.blank ? '_blank' : undefined}
rel={value?.blank ? 'noreferrer' : undefined}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
internalLink: ({ value, children }) => (
<a href={`/${value?.reference?.slug?.current}`} className="text-blue-600 hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
block: {
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>,
h3: ({ children }) => <h3 className="text-xl font-bold mt-6 mb-3">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-6 text-gray-600">
{children}
</blockquote>
),
},
}
export function PortableTextContent({ value }: { value: any[] }) {
return (
<div className="prose prose-lg max-w-none">
<PortableText value={value} components={components} />
</div>
)
}
Извлечение plain text для мета-описания
// GROQ — извлечь текст из Portable Text
*[_type == "post"][0] {
"description": pt::text(body)[0..160]
}
// Или в TypeScript через @portabletext/toolkit
import { toPlainText } from '@portabletext/toolkit'
const plainText = toPlainText(post.body)
const excerpt = plainText.slice(0, 160)
Сроки
Настройка Portable Text схемы с кастомными блоками и рендерером — 1–2 дня.







