🔮 Sanity Create is here. Writing is reinvented. Try now, no developer setup

groqd implementation with portable text custom marks

By Oybek Khalikovic

If you're working on integrating portable text with custom markDefs using groqd, the following code snippet could be very useful. It might save you a considerable amount of research time.

body.field.ts

import { defineField } from 'sanity';

const bodyLightBlock = defineField({
  name: 'body',
  title: 'Body',
  type: 'array',
  of: [
    {
      lists: [
        { title: 'Bullet', value: 'bullet' },
        { title: 'Numbered', value: 'number' },
      ],
      marks: {
        decorators: [
          {
            title: 'Italic',
            value: 'em',
          },
          {
            title: 'Strong',
            value: 'strong',
          },
        ],
        annotations: [
          // Email
          {
            name: 'annotationLinkEmail',
            type: 'annotationLinkEmail',
          },
          // Internal link
          {
            name: 'annotationLinkInternal',
            type: 'annotationLinkInternal',
          },
          // URL
          {
            name: 'annotationLinkExternal',
            type: 'annotationLinkExternal',
          },
        ],
      },
      // Paragraphs
      type: 'block',
    },
  ],
});

export default bodyLightBlock;

link-email.annotation.tsx

import { EnvelopeIcon } from '@sanity/icons';
import React from 'react';
import { defineField } from 'sanity';

export default defineField({
  title: 'Email link',
  name: 'annotationLinkEmail',
  type: 'object',
  icon: EnvelopeIcon,
  components: {
    annotation: (props) => (
      <span>
        <EnvelopeIcon
          style={{
            marginLeft: '0.05em',
            marginRight: '0.1em',
            width: '0.75em',
          }}
        />
        {props.renderDefault(props)}
      </span>
    ),
  },
  fields: [
    // Email
    {
      title: 'Email',
      name: 'email',
      type: 'email',
    },
  ],
  preview: {
    select: {
      email: 'email',
    },
  },
});

link-external.annotation.tsx

import { EarthGlobeIcon } from '@sanity/icons';
import React from 'react';
import { defineField } from 'sanity';

export default defineField({
  title: 'External Link',
  name: 'annotationLinkExternal',
  type: 'object',
  icon: EarthGlobeIcon,
  components: {
    annotation: (props) => (
      <span>
        <EarthGlobeIcon
          style={{
            marginLeft: '0.05em',
            marginRight: '0.1em',
            width: '0.75em',
          }}
        />
        {props.renderDefault(props)}
      </span>
    ),
  },
  fields: [
    {
      name: 'url',
      title: 'URL',
      type: 'url',
      validation: (Rule) => Rule.required().uri({ scheme: ['http', 'https'] }),
    },
    // Open in a new window
    {
      title: 'Open in a new window?',
      name: 'newWindow',
      type: 'boolean',
      initialValue: true,
    },
  ],
});

link-internal.annotation.tsx

import { LinkIcon } from '@sanity/icons';
import React from 'react';
import { defineField } from 'sanity';
import { PAGE_REFERENCES } from '@/features/core/sanity/constants';

export default defineField({
  title: 'Internal Link',
  name: 'annotationLinkInternal',
  type: 'object',
  icon: LinkIcon,
  components: {
    annotation: (props) => (
      <span>
        <LinkIcon
          style={{
            marginLeft: '0.05em',
            marginRight: '0.1em',
            width: '0.75em',
          }}
        />
        {props.renderDefault(props)}
      </span>
    ),
  },
  fields: [
    // Reference
    {
      name: 'reference',
      type: 'reference',
      weak: true,
      validation: (Rule) => Rule.required(),
      to: PAGE_REFERENCES,
    },
  ],
});

body.query.ts

import { InferType, q, type Selection, type TypeFromSelection } from 'groqd';

const annotationLinkExternalQuery = {
  _type: q.literal('annotationLinkExternal'),
  _key: q.string(),
  newWindow: q.boolean(),
  title: q.string(),
  url: q.string(),
} satisfies Selection;

const annotationLinkInternalQuery = {
  _type: q.literal('annotationLinkInternal'),
  _key: q.string(),
  link: q('reference')
    .deref()
    .grab$(
      {
        _type: q.string(),
        title: q.string(),
        slug: q.string(),
      },
      {
        '_type == "project"': {
          slug: ['"/project/" + slug.current', q.string()],
        },
        '_type == "home"': {
          slug: ['"/" + slug.current', q.string()],
        },
        '_type == "service"': {
          slug: ['"/service/" + slug.current', q.string()],
        },
        '_type == "category"': {
          slug: ['"/category/" + slug.current', q.string()],
        },
      }
    ),
} satisfies Selection;

const annotationLinkEmailQuery = {
  _type: q.literal('annotationLinkEmail'),
  _key: q.string(),
  email: q.string(),
} satisfies Selection;

const markDefsQuery = q('markDefs').filter().select({
  '_type == "annotationLinkEmail"': annotationLinkEmailQuery,
  '_type == "annotationLinkExternal"': annotationLinkExternalQuery,
  '_type == "annotationLinkInternal"': annotationLinkInternalQuery,
});

const bodyFieldQuery = {
  body: q('body')
    .filter()
    .grab$({
      _type: q.string(),
      _key: q.string(),
      children: q.array(
        q.object({
          _key: q.string(),
          _type: q.string(),
          text: q.string(),
          marks: q.array(q.string()),
        })
      ),
      markDefs: [markDefsQuery.query, markDefsQuery.schema.optional()],
      style: q.string().optional(),
      listItem: q.string().optional(),
      level: q.number().optional(),
    }),
} satisfies Selection;

type BodyFieldType = InferType<(typeof bodyFieldQuery)['body']>;
type AnnotationLinkExternalType = TypeFromSelection<
  typeof annotationLinkExternalQuery
>;
type AnnotationLinkInternalType = TypeFromSelection<
  typeof annotationLinkInternalQuery
>;
type AnnotationLinkEmailType = TypeFromSelection<
  typeof annotationLinkEmailQuery
>;

export {
  bodyFieldQuery,
  type AnnotationLinkExternalType,
  type AnnotationLinkInternalType,
  type AnnotationLinkEmailType,
  type BodyFieldType,
};

page.tsx

import { sanityFetch } from '@/sanity/lib/client';
import { InferType, q } from 'groqd';
import { bodyFieldQuery } from '@/features/core/fields/body/body.query';
import PortableText from '@/components/portable-text/portable-text';

export default async function Home() {
  const pageQuery = q('*[_type == "home"][0]').grab$({
    title: q.string(),
    active: q.boolean(),
    content: [bodyFieldQuery.body.query, bodyFieldQuery.body.schema],
    anotherContent: [bodyFieldQuery.body.query, bodyFieldQuery.body.schema],
  });

  type PageType = InferType<typeof pageQuery>;

  const data = await sanityFetch<PageType>({
    query: pageQuery.query,
    tags: ['page'],
  });

  return (
    <>
      <PortableText value={data.anotherContent} />
      <PortableText value={data.content} />
    </>
  );
}

Simple code snippet of groqd usage with portable text, groqd is amazing library that can help you to generate types and validate your groq schema.

we can also pre-define fields and queries and re-use them across different projects

The key components of my implementation involve several TypeScript files that work in tandem to achieve the desired functionality:

  1. body.field.ts: This file defines a custom field for structured content in Sanity. It meticulously specifies the types of content blocks, including different list styles and text decorations. Importantly, it incorporates custom annotations for various link types, enhancing the richness of the text.
  2. body.query.ts: This file likely handles the querying aspect, leveraging groqd's capabilities to fetch and manipulate the portable text data. The specifics of this file could include query definitions, data transformations, and integration points with the Sanity dataset.
  3. link-*.annotation.tsx files: These files (link-email.annotation.tsx, link-external.annotation.tsx, and link-internal.annotation.tsx) define custom annotations for different link types within the portable text. This modular approach ensures that each link type is handled appropriately, allowing for a more sophisticated and user-friendly content management experience.

By combining these elements, my solution offers a comprehensive approach to integrating portable text with custom markDefs using groqd. It's tailored to provide a seamless and efficient workflow for content management systems, significantly reducing the complexity and time investment typically required for such integrations.

we can use similar approach to define custom blocks in our portable text field configuration

This code could serve as a valuable reference or starting point for anyone facing similar challenges in their projects.

Contributor

Oybek Khalikovic

CTO at Karve Digital, an innovative technology leader spearheading digital transformation.

Oybek is located at Dubai/UAE
Visit Oybek Khalikovic's profile