UI: Building OG (Open Graph) previews

Presenting richer offsite links with automatic previews which are built automatically from the destination's OG tags.

Our goal is to set up a simple webservice that responds with OG metadata for any given url detailing the destination's title, description and an image that can be used to build a rich "preview" or "card" (like twitter and facebook do) for the link which will work even for the many sites that are not fully compliant with OG tags (it is sometimes necessary to fall back to using document head.title and a screenshot for previews.) Fortunately the node ecosystem has a few modules that vastly simplify this task:

Some screenshots of this in action:

The link preview is built on the fly from a simple html tag.

Working code can be found (and run) in the digitopia-example repository on github.

OgTag model

It is good practice to cache retrieved OG tags so we don't have to scrape them for every request.

The first step is to set up a model were we can Cache the OG metadata by url in common/models/OgTag.json

The remote methods to retrieve and cache OG tags by url are implemented in common/models/OgTag.js

The basic outline of this task is:

  1. look in OgTag table for the url
  2. If found we are done, return OG payload

If not found

  1. Retrieve the the HEAD for the url to determine if it still exists and read the content-type
  2. Scrape OG tags from url if applicable
  3. If no image found in OG tags, take a screenshot
  4. Save OgTag instance
  5. Save resized image or screenshot in s3
  6. Return OG payload

The OgTag model exposes 2 endpoints:

Return OG payload for previously cached urls (readonly):

/api/OgTags/lookup?url=<some url>

Scrape and return OG data from url if not already cached or return previously cached payload:

/api/OgTags/scrape?url=<some url>

The resulting response payload contains all the OG data and images needed to make the UI.

{
    "result": {
        "url": "http://blog.digitopia.com/white-fucking-space-boots/",
        "ogData": {
            "ogSiteName": "Some Bandages for the Bleeding Edge",
            "ogType": "article",
            "ogTitle": "Space: 1999 in Black and Blonde",
            "ogDescription": "The physics may have been a bit sketchy with all that \"moon flitting about the galaxy\" stuff but back in 1975, during those brief moments when the vertical hold stabilized on my tiny black and white tv, I sometimes witnessed...",
            "ogUrl": "http://blog.digitopia.com/white-fucking-space-boots/",
            "twitterCard": "summary_large_image",
            "twitterTitle": "Space: 1999 in Black and Blonde",
            "twitterDescription": "The physics may have been a bit sketchy with all that \"moon flitting about the galaxy\" stuff but back in 1975, during those brief moments when the vertical hold stabilized on my tiny black and white tv, I sometimes witnessed...",
            "ogImage": {
                "url": "http://blog.digitopia.com/content/images/2016/03/pluge.jpg"
            },
            "contentType": "text/html; charset=utf-8"
        },
        "id": 6,
        "createdOn": "2016-08-02T20:29:05.978Z",
        "updatedOn": "2016-08-02T20:29:05.979Z",
        "uploads": [{
            "property": "image",
            "type": "image/jpeg",
            "url": "https://s3.amazonaws.com/digitopia/OgTag-image/nB/k0/5C.jpeg",
            "filename": "http://blog.digitopia.com/content/images/2016/03/pluge.jpg",
            "bucket": "digitopia",
            "key": "OgTag-image/nB/k0/5C.jpeg",
            "imageSet": {
                "large": {
                    "suffix": "large",
                    "quality": 90,
                    "maxHeight": 1024,
                    "maxWidth": 1024,
                    "path": "/tmp/4173eab4-14c0-41d9-b01d-e605261b2f70large.jpeg",
                    "width": 1024,
                    "height": 757,
                    "awsImageAcl": "public-read",
                    "etag": "\"b44d0cddc0fcb07e8be7eb165deb0e31\"",
                    "key": "OgTag-image/nB/k0/5Clarge.jpeg",
                    "url": "https://s3.amazonaws.com/digitopia/OgTag-image/nB/k0/5Clarge.jpeg"
                },
                "medium": {
                    "suffix": "medium",
                    "quality": 90,
                    "maxHeight": 480,
                    "maxWidth": 480,
                    "path": "/tmp/4173eab4-14c0-41d9-b01d-e605261b2f70medium.jpeg",
                    "width": 480,
                    "height": 355,
                    "awsImageAcl": "public-read",
                    "etag": "\"652403643947b8e343ed01b25baec2db\"",
                    "key": "OgTag-image/nB/k0/5Cmedium.jpeg",
                    "url": "https://s3.amazonaws.com/digitopia/OgTag-image/nB/k0/5Cmedium.jpeg"
                },
                "original": {
                    "acl": "public-read",
                    "original": true,
                    "width": 1336,
                    "height": 988,
                    "path": "/tmp/4173eab4-14c0-41d9-b01d-e605261b2f70.jpeg",
                    "awsImageAcl": "public-read",
                    "etag": "\"253f16cb716e7be9615c06a9d3d072f6\"",
                    "key": "OgTag-image/nB/k0/5C.jpeg",
                    "url": "https://s3.amazonaws.com/digitopia/OgTag-image/nB/k0/5C.jpeg"
                }
            },
            "id": 6,
            "uploadableId": 6,
            "uploadableType": "OgTag"
        }]
    }
}

Notice that in addition to the og tags scraped from the page there is an array of 'uploads' which contains the various resized images as uploaded to s3 for use on the client side. The uploadable factory attaches s3 storage of images to any model. More details on this module is here.

Note: In this example we will use the /scrape form but in the real world this should probably not be open to the net at large as it could be used by anyone. A better scheme would be for an authorized host to make the call to /scrape to cache the OG Tags and images and for the client side to only use /lookup.

The client side UI

Building an asynchronously lazy loaded UI element to display the preview on pages using an html tag is a somewhat complex task.

All the functionality is encapsulated in an html (jade) tag:

.ogPreview.lazy-instantiate(data-lazy-jsclass="OgTagPreview" data-src="/api/OgTags/scrape" data-url="https://www.youtube.com/watch?v=G6Y5xLvYdMM" data-type="json")

This behavior of the tag is implemented as a Digitopia Controller

The data-jsclass defines the javascript controller to associate with the element. The data-src defines the endpoint and data-url defines the url of the link.

General outline of the controller's functionality:

  • when offscreen do nothing
  • when revealed by scrolling load the OG data from the endpoint specified in data-src
  • insert the title, description and image into the element
  • on click load in new tab unless a video in which case embed a player in the element
  • on hover reveal the detailed description and the full title which may have been truncated if too long to fit in the element when not hovered.
  • on small screens be full width otherwise define a reasonable max width

assets/js/ogTagPreview.js

(function ($) {
    function OgTagPreview(elem, options) {
        this.element = $(elem);
        var self = this;

        self.url = this.element.data('url');

        this.start = function () {
            // once digitopiaAjax has loaded the data from the endpoint, build the ui
            this.element.on('data', function (e, data) {
                self.data = data.result;
                var src = getUploadForProperty('image', data.result.uploads, 'medium', true).url;
                var img = $('<img data-jsclass="digitopiaLazyImg" data-lazy-src="' + src + '">');
                var caption = $('<div class="caption">');
                var site = data.result.ogData.ogSiteName ? data.result.ogData.ogSiteName : parseUri(self.url).host;
                var title = data.result.ogData.ogTitle ? data.result.ogData.ogTitle : 'Link'
                if (data.result.ogData.contentType && data.result.ogData.contentType.match(/^image\//i)) {
                    title = "Image";
                }
                caption.append('<h3>' + title + '<i class="glyphicon glyphicon-chevron-right"></i></h3>');
                if (data.result.ogData.ogDescription) {
                    caption.append('<h4>' + data.result.ogData.ogDescription + '</h4>');
                }
                caption.append('<small><em>' + site + '</em></small>');
                caption.append('</div>');
                self.element.append(img);
                self.element.append(caption);
                if (data.result.ogData.ogVideo) {
                    self.element.append('<div class="play"><i class="glyphicon glyphicon-play-circle"></i></div>');
                }
                self.element.digitopiaViewport({
                    'crop': true,
                    'blowup': true
                });
                didInjectContent(self.element);
            });

            // set up arguments to ajax call to OgTag endpoint specified in element's data-src
            self.element.digitopiaAjax({
                args: {
                    url: self.url
                }
            });

            // open url on click
            self.element.on('click', function (e) {
                // if video inject player
                if (self.data && self.data.ogData.ogVideo) {
                    self.element.empty().append('<iframe width="100%" height="100%" src="' + self.data.ogData.ogVideo.url + '?autoplay=1" frameborder="0" allowfullscreen></iframe>')
                }
                else {
                    var ref = window.open(self.url, '_blank');
                }
            });

            // do hover behavior
            self.element.on('mouseenter', function () {
                self.element.addClass('hovering');
            });
            self.element.on('mouseleave', function () {
                self.element.removeClass('hovering');
            });
        };

        self.stop = function () {
            this.element.off('data');
            this.element.off('click');
        };
    }
    $.fn.OgTagPreview = GetJQueryPlugin('OgTagPreview', OgTagPreview);
})(jQuery);

Finally, the css

The UI and the hover animation styles

assets/stylus/ogTagPreview.styl

@import 'nib'

.ogPreview
    height:350px
    cursor:pointer
    border-radius:6px
    border:6px solid #95c200
    background-color: #666
    margin-bottom:30px
    .not-digitopia-xsmall &
        max-width:450px
    &:hover
        border-color: #B3E801
    .play
        text-align:center
        color:white
        font-size:60px
        width:100%
        line-height:350px
        opacity:.2

    .caption
        transition: all 0.25s ease
        transform: translate3d(0,0,0)
        color: white
        background: linear-gradient(to bottom, rgba(0,0,0,0) 0%,rgba(0,0,0,0.9) 100%)
        padding:10px
        padding-top:20px
        position:absolute
        bottom:0px
        left:0px
        top:80%
        width:100%
        font-size:12px

        a
            color: white
        h3
            font-size:16px
            margin:0px
            padding-bottom:20px
            white-space: nowrap
            overflow: hidden
            text-overflow: ellipsis
            i
                font-size:12px
                opacity:.4
        h4
            transition: all 0.25s ease
            transition-delay: .25s
            transform: translate3d(0,0,0)
            font-size:12px
            height:0px
            opacity:0
        small
            position:absolute
            right:10px
            bottom:10px
            opacity:.6

.hovering
    .caption
        background: linear-gradient(to bottom, rgba(0,0,0,.4) 0%,rgba(0,0,0,0.9) 100%)
        top:0px
    .caption h3
        overflow:auto
        white-space:normal
        text-overflow:unset
    .caption h4
        height:auto
        opacity: 1

Note the @import 'nib' line - this tells grunt/stylus to append all the 'vendor' tags so we don't need to specify them.

The rules providing the functionality of revealing the detailed description from the OgTags are in the block
.hovering

The responsive behavior setting the conditional max-width is in the block
.not-digitopia-xsmall &


Photo: Outside Luxor, Egypt from Hot Air Balloon (2006)
Document version 1.1