From Sanity to Dev: Level up your blogging experience

wiommi

William Iommi

Posted on June 6, 2023

From Sanity to Dev: Level up your blogging experience

Hello and welcome back. Over the last few months, I have been exploring a new CMS, or to be more precise, a new Composable Content Cloud Platform, called Sanity.

As usual, I get bored pretty easily with the out-of-the-box features πŸ˜„ , and I like to enhance the authoring experience with customizations.

This will not be an article about the pros and cons or a product review. Instead, we are going to build a plugin and see what the platform offers in terms of customizations.

Let's have some fun, guys!πŸ‘¨β€πŸ”¬Β πŸš€



The why behind this plugin

The idea behind this plugin was to address a personal need I had.

Currently, on my personal website, I have a "writing" section that contains links to the articles I publish on DEV.

I use an endpoint provided by the platform to retrieve all my published articles and display them as links that redirect to the corresponding article.

However, my end goal, besides experimenting with Sanity, is to display full articles on my personal website with a custom design.

But at the same time, I want to be able to publish the same content on DEV, without leaving the Sanity Studio and without having to manually copy and paste the article content. I want a seamless integration that allows me to achieve both objectives with ease.


DEV endpoints

DEV is built on the Forem platform. The latter provides a series of endpoints that, through an API key generated by your DEV account, allow you to retrieve the articles you have published, as well as create, update, and publish articles.

For this plugin, we will be using the following endpoints:

To obtain the API key on DEV, you need to go to the following link and generate the key in the appropriate section:

Image description

As stated by the documentation, all the above endpoints are CORS-disabled.

This means that it is not possible to call them directly within the browser, and therefore within our plugin.

To overcome this, we will need to make these calls on the server-side. In my case, using the Studio within my Next.js project, I will use API Routes to make these calls.

As for how these calls will be implemented, they are out of scope for the article.

Below are some points to consider:

  • All calls must respect the documentation and always return a valid article object.
  • The GET receives a query parameter 'articleId' from the plugin.
  • The POST and PUT receive the article to be saved in the request body.

As we will see shortly, the plugin receives 3 input parameters representing the URLs to be called for proper functioning.


Init Sanity plugin with plugin-kit

For the creation of the plugin, we will use the official Sanity package, @sanity/plugin-kit.

It is a set of tools and utilities that help simplify the process of building plugins.

Following the provided documentation, we can start by running the following script:

npx @sanity/plugin-kit@latest init sanity-plugin-devtoarticle
Enter fullscreen mode Exit fullscreen mode

After following the setup proposed by the script, you will end up with the following folder structure:

Image description

The file that we are most interested in is index.ts inside the src folder.

import {definePlugin} from 'sanity'

interface DevArticleConfig {
  /* nothing here yet */
}

export const DevArticle = definePlugin<DevArticleConfig | void>((config = {}) => {
  return {
    name: 'sanity-plugin-devtoarticle',
  }
})
Enter fullscreen mode Exit fullscreen mode

As previously anticipated, API calls will be made on the server-side. What the plugin needs to know is only where to make them. For this reason, we will define mandatory input parameters to be provided when using the plugin inside the Studio:

interface DevtoArticleConfig {
  api: {
    get: string // api/devto/article/get
    create: string // api/devto/article/create
    update: string // api/devto/article/update
  }
}
Enter fullscreen mode Exit fullscreen mode

In my specific case, using the plugin inside a Nextjs project, the URLs will be relative paths to the api folder under pages.


Document structure

The main functionality of the plugin is to provide a Content Model called β€œDEV Article” with a set of fields necessary for creating the article on the external platform.

  • An Object field in read-only mode with a custom component, containing information from DEV: article ID, a flag to indicate whether the article is published or not, the generated slug, and the updateAt date.
  • Title
  • Description
  • Cover Image
  • Body: represented by a Portable Text Sanity capable of handling some custom blocks.
  • Tags: an array of strings containing up to 4 values.
  • Series: to identify whether the article belongs to a series of articles.
  • Canonical Url
  • Organization Id

Below you can see how it is defined in the code:

const DevtoArticle = (config: DevtoArticleConfig) =>
  defineType({
    type: 'document',
    name: 'devto.article',
    title: 'DEV Article',
    icon: FaDev, // an icon from react-icons
    preview: {
      select: {
        title: 'title',
        subtitle: 'description',
        media: 'cover_image',
      },
    },
    fields: [
      defineField({
        type: 'object',
        name: 'devto',
        title: 'DEV',
        readOnly: true,
        components: {
          field: (props) => DevField({...props, config}),
        },
        fields: [
          defineField({
            type: 'number',
            name: 'id',
            title: 'DEV ID',
          }),
          defineField({
            type: 'boolean',
            name: 'isPublished',
            title: 'Is Published',
          }),
          defineField({
            type: 'slug',
            name: 'slug',
            title: 'Slug',
          }),
          defineField({
            type: 'datetime',
            name: 'updatedAt',
            title: 'Updated At',
          }),
        ],
      }),
      defineField({
        type: 'string',
        name: 'title',
        title: 'Title',
        validation: (Rule) => Rule.required(),
      }),
      defineField({
        type: 'text',
        name: 'description',
        title: 'Description',
        rows: 5,
      }),
      defineField({
        type: 'image',
        name: 'cover_image',
        title: 'Cover Image',
        validation: (Rule) => Rule.required(),
      }),
      defineField({
        type: 'array',
        name: 'body',
        title: 'Body',
        validation: (Rule) => Rule.required(),
        of: [
          {
            type: 'block',
            marks: {
              decorators: [
                {title: 'Strong', value: 'strong'},
                {title: 'Emphasis', value: 'em'},
                {title: 'Code', value: 'code'},
                {title: 'Underline', value: 'underline'},
                {title: 'Strike', value: 'strike-through'},
                {
                  title: 'Inline Katex Notion',
                  value: 'devto.katexinline',
                  icon: TbMathFunction,
                  component: KatexInlinePreview,
                },
              ],
            },
          },
          {type: 'devto.image'},
          {type: 'code'},
          {type: 'devto.details'},
          {type: 'devto.embed'},
          {type: 'devto.katexblock'},
        ],
      }),
      defineField({
        type: 'array',
        name: 'tags',
        title: 'Tags',
        options: {
          layout: 'tags',
        },
        of: [
          {
            type: 'string',
          },
        ],
        validation: (Rule) => Rule.max(4),
      }),
      defineField({
        type: 'string',
        name: 'series',
        title: 'Series',
      }),
      defineField({
        type: 'url',
        name: 'canonical_url',
        title: 'Canonical URL',
      }),
      defineField({
        type: 'number',
        name: 'organization_id',
        title: 'Organization ID',
      }),
    ],
  })
Enter fullscreen mode Exit fullscreen mode

Custom components

Based on the definition of the document provided in the previous paragraph, in addition to several basic fields, there are also some custom ones. Let's take a closer look at them together:

DevField

components: {
  field: (props) => DevField({...props, config}),
},
Enter fullscreen mode Exit fullscreen mode

The custom component DevField is defined within the object containing the information extracted from DEV.

Inside the components object, we are going to override the field attribute. This operation, as indicated by the documentation, allows us to replace the UI of the entire field.

Our custom component receives, in addition to the default props, an object 'config' that represents the input parameters of the plugin. We will in fact need the 'get' URL to retrieve the article information and display them.

DevField - draft UI

As you can see, the component represents a summary of the article present on DEV. We have retrieved some useful information such as likes, comments, views, and reading time.

In addition to the title, description, and author, we also show the article status with a badge above the cover.

The 'Show Info' button, instead, allows you to see the data actually saved in the field, which, as previously indicated, are: id, isPublished flag, slug, date of last update.

DevField - modal UI

If the article is published on DEV, we also have some links available on the right side:

DevField - published UI

Portable Text Blocks

Portable Text is a powerful feature within Sanity that allows for a high level of customization and is much more than just a simple WYSIWYG editor. The idea of using it instead of a simple markdown editor is to provide the freedom to manage content as preferred within the website.

The field that we will analyze is the "body" field. The extensions that have been introduced mainly refer to the customization possibilities provided by DEV through some Liquid Tags. Let's take a look at them:

  • Katex Inline: allows us to insert a mathematical notation inline.
  • Katex Block: allows us to insert a mathematical notation into a separate block.
  • Details: allows us to define content as an accordion. The content of the accordion is also a portable text with fewer options.
  • Embed: allows us to embed an external resource. It is possible to choose whether to show a preview using an iframe.
  • Code: allows us to insert a code snippet using Sanity's official plugin.
  • Image: a custom object for inserting an image with the ability to define an alt text and a title for the image.

The Portable Text tool palette looks like this:

Portable Text - Tools
Portable Text - Tools

Each customization has its own preview within the editor. Here are a couple of examples:

Portable Text - Blocks


Custom workflows with document actions

Okay, so far we've seen how the content model is structured and some internal customizations. But how do we communicate and save information on DEV? That's where document actions come in handy πŸ˜ƒ.

As indicated in the official documentation, document actions allow you to perform some operations on the document.

So, we will add a series of actions on top of the default ones, and in the same way, we will remove one, the duplicate action, because we do not want to allow the user to duplicate this kind of document in the Studio.

To add our actions, we need to modify the configuration of our plugin by inserting our code inside the document.actions object.

export const DevArticle = definePlugin<DevArticleConfig | void>((config = {}) => {
  return {
    name: 'sanity-plugin-devtoarticle',
    // ... rest of the configuration ...
    document: {
      actions: (prev, context) => {
        // customization valid only for devto.article document.
        if (context.schemaType === 'devto.article') {
          // removing the duplicate action
          prev = prev.filter((item) => item.action !== 'duplicate');
          [
            TitleAction,
            CreateDraftAction,
            CreatePublishedAction,
            UpdateAction,
            PublishAction,
            UnpublishAction,
            UnlinkAction,
            DividerAction,
          ].forEach((action, index) =>
            prev.splice(index + 1, 0, (actionProps) => action(actionProps, config))
          )                 
        }
        return prev;
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

The actions will only be visible if certain conditions are met. The main condition for all, however, is that the document must be in the 'published' state on the Studio.

The 'TitleAction' and 'DividerAction' actions are just placeholders that allow you to visually separate custom actions from default ones.

Below are the possible combinations that can be obtained:

Article not present on DEV:

The visible actions are CreateDraftAction and CreatePublishedAction, which respectively allow you to create a draft document or create and directly publish the document on DEV.

article not present

Article present on DEV in DRAFT/UNPUBLISHED state:

Possible actions are UpdateAction, PublishAction, and UnlinkAction. The first two are self-explanatory, while the unlink action allows you to remove the association with the article on DEV. Both documents on the two platforms will not be deleted but can no longer be linked to each other (at least in this version of the plugin 😬).

article in draft

Article present on DEV in PUBLISHED state:

Possible actions are UpdateAction, UnpublishAction, and UnlinkAction. We have already seen two actions previously, while the Unpublish action allows you to return the document on DEV to draft state.

article published


Body serialization

Everything is awesome so far, at least for me πŸ˜„. But what about the body of the article? How do we transform the content into something that DEV can save and, above all, render correctly on the platform?

To achieve this, we will use the @sanity/block-content-to-markdown plugin to convert portable text into markdown.

The plugin block-content-to-markdown is in beta and hasn't received updates for many years, but so far, I haven't encountered any particular issues.

What we need to do is provide a series of serializers to transform those custom sections that otherwise would not be interpreted correctly.

const toMarkdown = require('@sanity/block-content-to-markdown')

toMarkdown($portableTextContent, {
  serializers: {
    marks: {
      'devto.katexinline': katexInlineSerialiizer,
    },
    types: {
      'devto.image': (props: any) => imageSerializer(props, projectId, dataset),
      'devto.details': (props: any) => detailsSerializer(props, projectId, dataset),
      'devto.embed': embedSerialiizer,
      'devto.katexblock': katexBlockSerialiizer,
      code: codeSerializer,
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Most serializers return a string with the corresponding liquid tag inside:

export const katexInlineSerialiizer = (props: any): string => {
  return `{% katex inline %}${props.children}{% endkatex %}`
}

export const katexBlockSerialiizer = (props: any): string => {
  return `{% katex %}\n${props.node.notation}\n{% endkatex %}`
}

export const embedSerialiizer = (props: any): string => {
  return `{% embed ${props.node.url} %}`
}
Enter fullscreen mode Exit fullscreen mode

Demo?

Alright folks, we've seen enough theory and blah blah blah! How about a little demonstration? Time to get our hands dirty πŸ€“πŸ˜Ž


Next steps and final thoughts

That's all for now, folks. I hope you enjoyed the content. You can find the Github repository at the following link.

The idea for the future is to add the ability to link to articles already present on DEV in addition to creating new ones. And maybe even create a widget to use in the Sanity Dashboard to get a recap of your articles.

Thank you for sticking around until the end!

See ya πŸ€™

πŸ’– πŸ’ͺ πŸ™… 🚩
wiommi
William Iommi

Posted on June 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related