Nuxtstop

For all things nuxt.js

How to build a multi-location app for the Open Graph protocol

How to build a multi-location app for the Open Graph protocol
4 0

I'm cross-posting this article for my Colleague David Fateh. Check out the original post over on the Contentful Blog.

If you are a content creator for the web, you may have used the Open Graph protocol. The Open Graph protocol is a way to display and share information about your resource on the internet. You may have seen these in action when sharing websites and URLs on Twitter and Facebook, or when you are chatting with your coworkers on Slack. The preview and metadata for the link is shown to the user as in the Twitter post below.

Screenshot of Harald Wartig's tweet

Building an app on the App Framework can be as simple as creating a single custom button or as complicated as composing multiple components in different locations of Contentful. I want to run through an example app I've created which makes use of the App Framework's ability to handle complexity, but more importantly shows how to pass data between different app locations.

In Contentful, we can create our own Open Graph data by making use of a simple content type that provides the user with the necessary fields to show these kinds of previews. Then, using the App Framework, we can augment the editor experience to allow for automated title generation of our content as well as a visual preview.

In this post, we will cover a two things: 1. Creating a content type to handle simple Open Graph data. 2. Creating an app which helps visualize the data and allows for a preview mode. 3. When all is said and done, our Open Graph content type will still need to be pulled from a Contentful API response when we generate our web pages. The information contained in an Open Graph entry will be enough information to populate meta tags in the head of our HTML filles. Let's get started!

Creating the content type

We can start by creating a new content type in our space called "Open Graph," which will have five fields:

  1. Title: short text

  2. Content: single reference (in our case, referencing blog posts)

  3. Type: short text

  4. Image: media

  5. URL: short text

Screenshot of Contentful content type

This content type does a few things. First, it allows editors to pick a reference entry. In many cases, this could be a blog post, web page, link or other type of media. In our example, we will be using the Open Graph content type in a blog. This means our Content reference field will reference "Blog Post" content types.

Second, we can automate the "Title" field by asking the editor to select a blog post and grabbing the title from that post. This is something we will look at when we dive into the code.

Lastly, we can create a custom button that will show the editor a preview of our Open Graph data as it might appear in a card UI such as Twitter or Facebook.

Now that we have our content type set up, let's start developing our app.

Creating the app

For those that are familiar with the App Framework and want to see the source code of the app, you can see it here on GitHub. For those less familiar, I've written a few tutorials in the past about how to build apps. We also have a tutorial for beginners, Building your first app, where we explain all the necessary steps to get up and running.

An app is a single page application that runs inside of an iframe and can be rendered in different locations inside of the Contentful Web App. While building an app can be done using any framework (or lack thereof), I'm going to use the Create Contentful App CLI tool, which will bootstrap a React app with all the necessary logic required to get up and running inside of Contentful.

To initialize and run our app locally, run these commands in your terminal:

npx @contentful/create-contentful-app init open-graph
cd open-graph
npm start
Enter fullscreen mode Exit fullscreen mode

The app is now running as a single page application on http://localhost:3000. In order to see our app in Contentful, we will need to create an AppDefinition and assign our app to show up in the correct locations.

Creating the AppDefinition

An AppDefinition is the entity that represents an app in Contentful. It contains general app information, like where it is visible. It also provides settings that enable it to run independently, or enable particular settings for any current and future installations.

Note: In order to create an AppDefinition, you must be a Contentful organization admin or developer. If you are not, you can ask your admin for access or sign up for a free Contentful account to use as a sandbox.

Let's create a new AppDefinition. Go to your organization settings. In the top menu, select Apps. From here we will create a new AppDefinition with the following properties:

  1. Name: Open Graph

  2. App URL: http://localhost:3000 (remember this is running on our machine)

  3. First location: Field - short text

  4. Second location: Page

Screenshot of the new AppDefinition field in Contentful

Once filled in, hit the Create button in the top-right corner. Next, let's install our app into the space where we created our Open Graph content type.

Installing and developing the app

To install the app, head over to the space where we created our Open Graph content type. In the Apps menu bar, click "Manage apps" then find the Open Graph app in the list of available apps (it will show up with a private tag next to it). Click Install.

Once our app is installed, it is ready to be assigned to our Open Graph content type. Navigate to the Content Model page and select the Open Graph content type. In there we will modify the settings of our "Title" field by navigating to the appearance area selecting our Open Graph app. Click save.

Illustration of the Open Graph content type in Contentful

Now, create a new Open Graph entry to see how the app renders. We can see that our app is rendering by the message that is showing up in our Title field. Of course, we'd like it if our title field actually showed a title instead of a developer message, so at this point it is time to dive into the code that was created earlier by our create-contentful-app CLI tool.

To start, create a field that shows a title based off of the reference blog post in our Content field. To get this functionality, we will make use of Forma 36 --- the Contentful Design System to implement UI elements which have the same look and feel as Contentful itself. We're going to use a TextInput which will be disabled for manual editing but will automatically pull the title information from our content reference field.

Screenshot of the process of creating an Open Graph entry

In our code, modify src/components/Field.tsx to create a better experience.

To start, import a few libraries that come out of the box when we ran the create-contentful-app CLI commands:

import React, {useState, useEffect} from 'react';
import tokens from '@contentful/forma-36-tokens'
import { TextInput, Note, Button } from '@contentful/forma-36-react-components';
import { FieldExtensionSDK } from '@contentful/app-sdk';
Enter fullscreen mode Exit fullscreen mode

We are using React here for our UI. Tokens is a Forma 36 utility that will provide us with certain variables like color and sizing. The Forma 36 components also come out of the box and will be useful for constructing our UI. Since we are using TypeScript, I'm also including the SDK typings by default.

Next, we'll want to modify the component itself to handle state, pull information from another field and render some UI.

First, create some state:

const Field = (props: FieldProps) => {
  // Get the title of the field and create a setter
  const [title, setTitle] = useState(props.sdk.field.getValue() || null);
Enter fullscreen mode Exit fullscreen mode

Next, listen for changes to our content reference field. When the content field has been populated with data, grab the entry, find the title and set that as the title in state. Also save that as the title field's value in the Open Graph content type.

  useEffect(() => {
    // Resize the field so it isn't cut off in the UI
    props.sdk.window.startAutoResizer();

    // when the value of the `content` field changes, set the title
    props.sdk.entry.fields.content.onValueChanged((entry) => {
      if (!entry) {
        setTitle(null);
        return;
      }

      // The content field is a reference to a blog post.
      // We want to grab that full entry to get the `title` of it
      props.sdk.space.getEntry<BlogPost>(entry.sys.id).then((data) => {
        if (data.fields.title['en-US'] !== title) {
          const title = data.fields.title['en-US'];
          setTitle(title);
          props.sdk.field.setValue(title);
        }
      });
    });
  });
Enter fullscreen mode Exit fullscreen mode

Last, we can render our UI based on the above logic to give editors some validation hints. We can either warn that a reference needs to be picked, or if a reference is picked, to use the correct title information in our field. We are also going to include a button, which will open up our page location by passing the entry's ID. We will touch on that more below.

  if (title === null) {
    return <Note noteType="warning">Link a blog post below to assign a title.</Note>
  }

  return (
    <>
      <TextInput disabled value={title} style={{marginBottom: tokens.spacingM}}/>
      <Button onClick={() => props.sdk.navigator.openCurrentAppPage({path: `/${props.sdk.entry.getSys().id}`})} buttonType="naked">Preview Open Graph</Button>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Our full component code looks like this now:

import React, {useState, useEffect} from 'react';
import tokens from '@contentful/forma-36-tokens'
import { TextInput, Note, Button } from '@contentful/forma-36-react-components';
import { FieldExtensionSDK } from '@contentful/app-sdk';

interface FieldProps {
  sdk: FieldExtensionSDK;
}
// Custom type to denote a blog post content type
interface BlogPost {
  sys: {
    id: string;
  };
  fields: {
    body: object;
    title: {
      'en-US': string;
    };
  };
};

const Field = (props: FieldProps) => {
  // Get the title of the field and create a setter
  const [title, setTitle] = useState(props.sdk.field.getValue() || null);

  useEffect(() => {
    // Resize the field so it isn't cut off in the UI
    props.sdk.window.startAutoResizer();

    // when the value of the `content` field changes, set the title
    props.sdk.entry.fields.content.onValueChanged((entry) => {
      if (!entry) {
        setTitle(null);
        return;
      }

      // The content field is a reference to a blog post.
      // We want to grab that full entry to get the `title` of it
      props.sdk.space.getEntry<BlogPost>(entry.sys.id).then((data) => {
        if (data.fields.title['en-US'] !== title) {
          const title = data.fields.title['en-US'];
          setTitle(title);
          props.sdk.field.setValue(title);
        }
      });
    });
  });

  if (title === null) {
    return <Note noteType="warning">Link a blog post below to assign a title.</Note>
  }

  return (
    <>
      <TextInput disabled value={title} style={{marginBottom: tokens.spacingM}}/>
      <Button onClick={() => props.sdk.navigator.openCurrentAppPage({path: `/${props.sdk.entry.getSys().id}`})} buttonType="naked">Preview Open Graph</Button>
    </>
  );
};

export default Field;
Enter fullscreen mode Exit fullscreen mode

Now that we have a field that can automatically pull a title from the referenced blog post, we also want to build out a page location, which will show a preview of the Open Graph to editors when they click the preview button. The button is under our Title and says "Preview Open Graph." This button will direct us to our page location which we will create soon.

Screenshot of Open Graph title preview function

Let's start by modifying our src/components/Page.tsx file. Again, we are going to import a few things from Forma 36 in order to build a nice preview UI for our Open Graph components.

import React, { useState, useEffect } from 'react';
import {
    Card,
    Note,
    Heading,
    Paragraph,
    Typography,
    TextLink,
    Button,
    SkeletonBodyText,
    SkeletonContainer,
    SkeletonDisplayText,
} from '@contentful/forma-36-react-components';
import tokens from '@contentful/forma-36-tokens';
import { PageExtensionSDK } from '@contentful/app-sdk';

In the Page component, check the URL parameters that we passed in when the editor clicked the button:

const Page = (props: PageProps) => {
    // false indicates an error occurred
    // otherwise the entry is loading or loaded
    const [entry, setEntry] = useState<OpenGraphPreview | false>();

    useEffect(() => {
        // Get the entry ID which is passed in via the URL
        const entryId = props.sdk.parameters?.invocation?.path.replace('/', '');

        // If no entry ID exists, show an error message by setting content to false
        if (!entryId) {
            setEntry(false);
            return;
        }
Enter fullscreen mode Exit fullscreen mode

We use sdk.parameters.invocation.path to access the custom path in the URL of our app, which was passed in by sdk.navigator.openCurrentAppPage from our field component as the on click function of the button.

Now, complete the useEffect function with some network calls to get all the data we need to display the preview:

        // Get the entry data by getting the linked asset and content body
        props.sdk.space.getEntry<OpenGraphEntry>(entryId).then((data) => {
            Promise.all([
                // Grabs the Image asset of the Open Graph content type
                props.sdk.space.getAsset<Asset>(
                    data.fields.image['en-US'].sys.id
                ),
                // Grabs the long text body of the blog post
                props.sdk.space.getEntry<Content>(
                    data.fields.content['en-US'].sys.id
                ),
            ]).then(([asset, content]) => {
                // combine the data from the two `space` calls
                setEntry({
                    title: data.fields.title['en-US'],
                    imageUrl: asset.fields.file['en-US'].url,
                    previewBody: content.fields.body['en-US'],
                    url: data.fields.url['en-US'],
                    id: entryId,
                });
            });
        });
    }, []);
Enter fullscreen mode Exit fullscreen mode

Notice that we're using Promise.all to get data about the entry and the image asset which we will use in our preview.

Now render the component UI:

    if (entry === false) {
        return <Note noteType="negative">Error retrieving entry!</Note>;
    }

    return (
        <div
            style={{
                display: 'flex',
                width: '100%',
                height: '100vh',
                alignItems: 'center',
                marginTop: tokens.spacingXl,
                flexDirection: 'column',
            }}
        >
            <div style={{ justifyContent: 'center' }}>
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={3} offsetTop={35} />
                </SkeletonContainer>
            </div>
            <Typography>
                <Heading>
                    Open Graph Preview
                </Heading>
            </Typography>
            <Card style={{ width: '260px' }}>
                {!entry ?
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={3} offsetTop={35} />
                </SkeletonContainer> :
                <>
                    <Typography>
                        <TextLink href={entry.url}>{entry.title}</TextLink>
                        <Paragraph>{entry.previewBody}</Paragraph>
                    </Typography>
                    <img src={entry.imageUrl} alt="" style={{ width: '200px' }} />
                </>}
            </Card>
            <Button
                buttonType="muted"
                disabled={!entry}
                onClick={() => props.sdk.navigator.openEntry(entry!.id)}
                style={{ margin: `${tokens.spacingXl} 0` }}
            >
                Back to entry
            </Button>
            <div style={{ justifyContent: 'center' }}>
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={10} offsetTop={35} />
                </SkeletonContainer>
            </div>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

For the most part, I am using Skeleton components, which are for looks; they show a loading preview. The Card component is used for actually displaying the content in the Open Graph card. All together, the Page component code looks like this:

import React, { useState, useEffect } from 'react';
import {
    Card,
    Note,
    Heading,
    Paragraph,
    Typography,
    TextLink,
    Button,
    SkeletonBodyText,
    SkeletonContainer,
    SkeletonDisplayText,
} from '@contentful/forma-36-react-components';
import tokens from '@contentful/forma-36-tokens';
import { PageExtensionSDK } from '@contentful/app-sdk';

interface PageProps {
    sdk: PageExtensionSDK;
}

// An open graph content type
interface OpenGraphEntry {
    fields: {
        title: {
            'en-US': string;
        };
        type: {
            'en-US': string;
        };
        url: {
            'en-US': string;
        };
        image: {
            'en-US': {
                sys: {
                    id: string;
                };
            };
        };
        content: {
            'en-US': {
                sys: {
                    id: string;
                };
            };
        };
    };
    sys: {
        id: string;
    };
}

// A custom shape for the entry we are going to render in our UI
interface OpenGraphPreview {
    id: string;
    title: string;
    imageUrl: string;
    previewBody: string;
    url: string;
}

interface Content {
    fields: {
        body: {
            'en-US': string;
        };
    };
}

interface Asset {
    fields: {
        file: {
            'en-US': {
                url: string;
            };
        };
    };
}

const Page = (props: PageProps) => {
    // false indicates an error occurred
    // otherwise the entry is loading or loaded
    const [entry, setEntry] = useState<OpenGraphPreview | false>();

    useEffect(() => {
        // Get the entry ID which is passed in via the URL
        // @ts-ignore
        const entryId = props.sdk.parameters?.invocation?.path.replace('/', '');

        // If no entry ID exists, show an error message by setting content to false
        if (!entryId) {
            setEntry(false);
            return;
        }

        // Get the entry data by getting the linked asset and content body
        props.sdk.space.getEntry<OpenGraphEntry>(entryId).then((data) => {
            Promise.all([
                // Grabs the Image asset of the Open Graph content type
                props.sdk.space.getAsset<Asset>(
                    data.fields.image['en-US'].sys.id
                ),
                // Grabs the long text body of the blog post
                props.sdk.space.getEntry<Content>(
                    data.fields.content['en-US'].sys.id
                ),
            ]).then(([asset, content]) => {
                // combine the data from the two `space` calls
                setEntry({
                    title: data.fields.title['en-US'],
                    imageUrl: asset.fields.file['en-US'].url,
                    previewBody: content.fields.body['en-US'],
                    url: data.fields.url['en-US'],
                    id: entryId,
                });
            });
        });
    }, []);

    if (entry === false) {
        return <Note noteType="negative">Error retrieving entry!</Note>;
    }

    return (
        <div
            style={{
                display: 'flex',
                width: '100%',
                height: '100vh',
                alignItems: 'center',
                marginTop: tokens.spacingXl,
                flexDirection: 'column',
            }}
        >
            <div style={{ justifyContent: 'center' }}>
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={3} offsetTop={35} />
                </SkeletonContainer>
            </div>
            <Typography>
                <Heading>
                    Open Graph Preview
                </Heading>
            </Typography>
            <Card style={{ width: '260px' }}>
                {!entry ?
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={3} offsetTop={35} />
                </SkeletonContainer> :
                <>
                    <Typography>
                        <TextLink href={entry.url}>{entry.title}</TextLink>
                        <Paragraph>{entry.previewBody}</Paragraph>
                    </Typography>
                    <img src={entry.imageUrl} alt="" style={{ width: '200px' }} />
                </>}
            </Card>
            <Button
                buttonType="muted"
                disabled={!entry}
                onClick={() => props.sdk.navigator.openEntry(entry!.id)}
                style={{ margin: `${tokens.spacingXl} 0` }}
            >
                Back to entry
            </Button>
            <div style={{ justifyContent: 'center' }}>
                <SkeletonContainer>
                    <SkeletonDisplayText numberOfLines={1} />
                    <SkeletonBodyText numberOfLines={10} offsetTop={35} />
                </SkeletonContainer>
            </div>
        </div>
    );
};

export default Page;
Enter fullscreen mode Exit fullscreen mode

In the final code, I've added some Typescript interfaces to ensure we are working with the correct data structures. If you'd like to check out the complete code, visit the repo: davidfateh/ctfl-open-graph: Multi location app for Open Graph.

If you'd like to go beyond this functionality, Salma recently wrote a post on solving this issue using Puppeteer and Node.js to generate this data programmatically.

Final thoughts

Passing data between app locations can be very useful for creating cohesive experiences for editors as they work on content that is meant to be visualized in different ways. The Open Graph app is one example of how retrieving entry data and passing it around inside of an app can extend some basic functionality.

If you have an app idea or are working on a prototype, our Slack Community can be very helpful if you want to share or get help implementing ideas. You can take advantage of some of the cool things we are doing and discussing over there. Our extensibility team and I are active in our community, and we are always curious to hear ideas about your next big projects!