hdoro / sanity-plugin-asset-source-ogimage

Allow editors to generate social sharing images on the fly inside of Sanity πŸ”₯
56 stars 3 forks source link

⚠️ Notice: I no longer mantain this repository. I won't reply to requests, bug reports or PRs. Feel free to fork and deploy your own version, you have my blessing.

Sanity Asset Source Plugin: og:image generation

Allow editors to easily create sharing images customized to their branding and show a preview of how the content will look on social media.

GIF showing this plugin in action

You can follow a video tutorial or read through the installing section below:

Screenshot of the video

Installing

This plugin is not installable via sanity install because it requires extra configuration to work. Start by installing it through npm / yarn:

# Install the package in your project, but don't activate it in Sanity
npm install sanity-plugin-asset-source-ogimage
yarn install sanity-plugin-asset-source-ogimage

🚨 You need @sanity/core 2.2.0 or greater.

Adding it to image fields

// Import the default image upload component or whatever other asset source you want to provide
import DefaultSource from 'part:@sanity/form-builder/input/image/asset-source-default';

// And the default export from this plugin
import OgImageGenerator from 'sanity-plugin-asset-source-ogimage';

// then in the schema, add options.sources to your image field
{
  name: 'ogImage',
  title: 'Social sharing image',
  type: 'image',
  options: {
    sources: [DefaultSource, OgImageGenerator],
  },
}
// ...
// Alternatively, you could transform ogImage in its own
// reusable schema if it's being used in many places.

πŸ‘‰ You'll definitely want to customize this plugin with your own image layouts. Refer to the customizing section for that.

You can also add it to all image fields by implementing part:@sanity/form-builder/input/image/asset-sources in your sanity.json:

// sanity.json
"parts": [
  //...
  {
    "implements": "part:@sanity/form-builder/input/image/asset-sources",
    "path": "./parts/assetSources.js"
  }
]
// /parts/assetSources.js
// Import the default image upload component or whatever other asset source you want to provide
import DefaultSource from 'part:@sanity/form-builder/input/image/asset-source-default'

// And the default export from this plugin
import OgImageGenerator from 'sanity-plugin-asset-source-ogimage'

export default [DefaultSource, OgImageGenerator]

🚨 Be careful when using this method, as it'll add this asset source to all image fields, which is probably not what you want.

Adding it as a studio tool

If you want it to be accessible at all times to editors, then you can add it as a studio tool to be linked from the Sanity top navbar:

// sanity.json
"parts": [
  //...
  {
    "implements": "part:@sanity/base/tool",
    "path": "parts/sharingImageTool.js"
  }
]
// parts/sharingImageTool.js
import React from 'react'
import { MediaEditor } from 'sanity-plugin-asset-source-ogimage'
import TwitterImageLayout from './TwitterImageLayout'

export default {
  name: 'sharing-image',
  title: 'Generate image',
  component: MediaEditor,
}

πŸ’‘ When used as a studio tool, generating an image will prompt users to download it instead of adding it to a document as there's no selected document. This makes for a very useful tool for generating images to post on Social media and similar.

Screenshot showing this plugin as a studio tool


These methods are going to get you a barebones starter, which you can customize by following the guide below.

Customizing

At the heart of this plugin we have layouts. Each layout defines how to transform a document into data, what fields editors should be able to edit and what React component to use to render the final image.

All the fields a layout can have are as follow:

type EditorLayout<Data = LayoutData> = {
  /**
   * Needs to be unique to identify this layout among others.
   */
  name: string
  /**
   * Visible label to users. Only shows when we have 2 or more layouts.
   */
  title?: string
  /**
   * React component which renders
   */
  component?: React.Component<Data> | React.FC<Data>
  /**
   * Function which gets the current document and generates a data object from it.
   * It's only ran when the layout is first booted up. Users will be able to overwrite its data after that.
   * Is irrelevant in the context of studio tools as the layout won't receive a document, so if you're only using it there you can ignore this.
   */
  prepare?: (document: SanityDocument) => Data
  /**
   * Fields editable by users to change the component data and see changes in the layout live.
   */
  fields?: {
    /**
     * Labels for editors changing the value of the property live.
     */
    title: string
    description?: string
    name: string
    /**
     * Array, date, datetime, reference and image aren't supported (yet?)
     */
    type: SanityFieldTypes
    /**
     * Helpful error message for editors when they can't edit that given field in the Editor dialog.
     * Exclusive to non-supported types
     */
    unsupportedError?: string
  }[]
  /**
   * Common examples include:
   * 1200x630 - Twitter, LinkedIn & Facebook
   * 256x256 - WhatsApp
   * 1080x1080 - Instagram square
   */
  dimensions?: {
    width: number
    height: number
  }
}

Creating a layout

Let's walk you through creating a simple blog post sharing image layout for Instagram. It shows the current author, title of the post, date of publication and an optional subtitle. Here's how we'd define it:

import React from 'react'
// Special component that renders the src for a given `_type: "image"` object
import { Image } from 'sanity-plugin-asset-source-ogimage'

export const blogPostInstagramLayout = {
  name: 'blogPostInstagram',
  title: 'Blog post (Instagram)',
  // Start defining the form editors will fill to change the final image
  fields: [
    {
      name: 'title',
      type: 'string',
    },
    {
      name: 'subtitle',
      description: '❓ Optional',
      type: 'string',
    },
    {
      name: 'date',
      // ideally, it'd be a date, but that input isn't implemented yet
      type: 'string',
    },
    {
      name: 'authorImage',
      title: "Author's image",
      type: 'image',
      unsupportedError:
        'We get this automatically from the chosen author. Close this dialog and change it in the document to reflect it here.',
    },
    {
      name: 'authorName',
      type: 'string',
    },
  ],
  prepare: (document) => {
    return {
      title: document.title,
      subtitle: document.subtitle || document.excerpt,
      date: new Date(
        document._createdAt ? document._createdAt : Date.now(),
      ).toLocaleDateString('en'),
      authorImage: document.author?.image,
      authorName: document.author?.name,
    }
  },
  dimensions: {
    width: 1080,
    height: 1080,
  },
  component: ({ title, subtitle, date, authorImage, authorName }) => (
    <div>
      <h1>{title || 'Please insert a title'}</h1>
      {subtitle && <h2>{subtitle}</h2>}
      {date && <div>{date}</div>}
      {authorImage && authorName && (
        <div style={{ display: 'flex', alignItems: 'center' }}>
          <Image image={authorImage} width={100} />
          {authorName}
        </div>
      )}
    </div>
  ),
}

πŸ” To recap, above:

Adding a custom layout to your generator

In the Installing section above we added this plugin's default export - now we'll customize it. Let's use the initial example of adding it to an image field:

import React from 'react'
import { MediaEditor } from 'sanity-plugin-asset-source-ogimage';

// taken from the layout we built above
import { blogPostInstagramLayout } from './blogPostInstagramLayout'
// And let's pretend we have another layout
import { blogPostTwitterLayout } from './blogPostTwitterLayout'

// in the schema, add options.sources to your image field
{
  name: 'ogImage',
  title: 'Social sharing image',
  type: 'image',
  options: {
    sources: [
      {
        name: 'sharing-image',
        title: 'Generate sharing image',
        component: (props) => (
          <MediaEditor
            // It's vital to forward props to MediaEditor
            {...props}
            // Our custom layouts
            layouts={[
              blogPostInstagramLayout,
              blogPostTwitterLayout,
            ]}
            // See dialog section below
            dialog={{
              title: 'Create sharing image',
            }}
          />
        ),
        icon: () => <div>🎨</div>,
      }
    ],
  },
}

Refer to Example Layouts below for inspiration πŸ˜€

Changing dialog labels

If you're building a non-english studio or just want to customize your wording, you can pass a dialog object to MediaEditor with the following:

interface DialogLabels {
  /**
   * Title above the dialog.
   */
  title?: string
  /**
   * Text of the generation button.
   */
  finishCta?: string
  /**
   * The a11y title for the close button in the dialog.
   */
  ariaClose?: string
}

In practice:

// ...
<MediaEditor
  {...props}
  dialog={{
    title: 'Generate art',
    finishCta: 'Blow their minds!',
  }}
/>
// ...

Example layouts

Refer to src/testLayouts for layout examples you can learn from.

Here's a list of ideas for inspiration:

Known limits

This plugin uses the excellent html-to-image package to render a base64 PNG string out of the DOM element created by your layouts. It does that by converting the result to an SVG, which gets rendered into a <canvas> element which, finally, the browser can handle and download.

It's a cumbersome process where many things can go wrong, so here are some things to watch out for:

Querying all images generated by this plugin

Based on my experience, it's often the case that editors use the plugin to generate an image only to replace it later, leaving the generated image unused.

That's intentional, as we're giving them the option to quickly get something generic that is good enough for the majority of use cases, but also allowing for replacing them later with a bespoke image.

However, this quickly amounts to a bunch of unused image files which pollute our search and increase our monthly costs (not to mention the energy cost of keeping those in store!). To deal with that, you can run the following query to find all those unused ones and delete them using Sanity's CLI.

{
  "all": *[
    _type == "sanity.imageAsset" &&
    source.name == "asset-source-ogimage"
  ] {
    _id,
    _createdAt,
    url,
  },
  "inUse": *[
    _type == "sanity.imageAsset" &&
    source.name == "asset-source-ogimage" &&
    count(*[references(^._id)]) > 0
  ] {
    _id,
    _createdAt,
    url,
  },
  "unused-delete-me": *[
    _type == "sanity.imageAsset" &&
    source.name == "asset-source-ogimage" &&
    count(*[references(^._id)]) <= 0
  ] {
    _id,
    _createdAt,
    url,
  },

Brain dump of ideas for the future

Ideas from the community

Between parenthesis is the Twitch username of who suggested these:

Contributing

As long as you are respectful, feel free to chime in with ideas, bug reports, feature requests and PRs 😊

Oh, and do submit screenshots of your layouts! This way we can help others with inspiration - let's help our editors rock o/