Translating schema.org's postalAddress microdata into useful links for mobile

In my previous post I wrote about the URL structures required to open map apps on mobile devices. Unfortunately there is no standard way to open these apps and a different URL structure is required for each of the popular mobile platforms: iOS, Android, WP7 and Blackberry. What I did not cover previously and the focus of this article, is how to implement this on your website.

Ultimately, our goal is to make our markup more functional. To do that we need to give it structure so our code can find and extract the data we need. We could create our own structure but instead let's look at some which already exists and will give our markup even more meaning. Schema.org is a project put together by Google, Microsoft and Yahoo which defines "structured data markup schema" for a variety of different data types including the one we'll look at today, PostalAddress. The additional benefit to using their structure is that it makes our website easier for search engines to understand and therefore to return more accurate and relevant results regarding our information.

Ideally, browser developers will eventually come together with a unified method we can use to launch map applications from our markup. This would eliminate the need for the plugin we'll be building. Who knows, maybe a URL won't even be needed and the browser will simply recognize the schema markup and let you launch your map app without a link. Maps and addresses are only one example of this kind of relationship, many others could also be harnessed to improve the overall usability of the web.

The Markup

Let's look at the structure we'll be using. Schema.org prescribes the following HTML markup for a street address. It contains all the information we'll need to open a map application, drop a pin at our desired location, and plot a route there.

<p itemscope itemtype='schema.org/PostalAddress'>
    <span itemprop='name'>Zelen Shoes</span><br />
    <span itemprop='streetAddress'>894 Granville Street</span><br />
    <span itemprop='addressLocality'>Vancouver</span>, 
    <span itemprop='addressRegion'>BC</span><br />
    <span itemprop='postalCode'>V6Z 1K3</span>
    <span itemprop='addressCountry'>Canada</span><br />
</p>

The above markup gives us an address that can easily be read by both humans and machines, but that is only the first step. What it still does not do is open a map. For that to work we'll need an anchor tag with a specific URL. The URL we construct depends on both our address information and our user's device. For more information on why the device matters see my previous post.

The Plugin

To construct the URL we'll be using a jQuery plugin. Alternatives to this approach would also work. A script could be written that does not rely on jQuery, or if your content is being populated by a CMS, back-end code could be written using the same principals. For today, jQuery will serve our needs.

I have posted the plugin on jsfiddle.net/johnallan/YkMA2/

There are four main steps needed to get us to our goal of creating a link that will open a map.

  1. Get the location data from the HTML
  2. Get the device type from the user-agent string
  3. Build the URL based on the device type and location data
  4. Wrap the existing HTML in an anchor tag

Unless you want a custom implementation the plugin does not require any settings and can be instantiated like this:

$("[itemtype='schema.org/PostalAddress']").linkSchemaData();

You'll notice that the plugin name is not specific to our exact intent. The name is intentionally general as this plugin is quite versatile. It could be used to wrap any kind of micro data with an anchor tag. This can be accomplished by providing new settings when you instantiate the plugin. At this point the default behavior is for addresses and maps but in the future I'd like to expand it to automatically handle other data types.

I have included the whole plugin as it currently exists at the bottom of the post. Make sure to check the fiddle for updates. And if you come up with improvements please share them in the comments!

The plugin's main function is as follows.

return this.each(function() {
    var $this, a, href, schemaData, deviceType;

    //pull location date from the HTML
    schemaData = methods.getSchemaData($this);
    //determine the device type by user-agent
    deviceType = methods.getDeviceType();
    //build the URL using the location data and device type
    href = methods.buildURL(deviceType, schemaData);
    //create the anchor tag
    a = $("<a />").attr({
        "href": href,
        "title": settings.linkTitle,
        "class": settings.linkClass,
        "target": (settings.openInNewWindow) ? (settings.openInSameWindow) ? "mapLinkWindow" : "_blank" : "_self"
    });
    //inject the anchor tag into the DOM
    $this.wrapInner(a);
});

For a closer look let's examine each of the three functions: getSchemaData, getDeviceType, and buildURL in detail.

getSchemaData

Before we look at the function itself we should look at a couple of the plugin settings that it uses: settings.dataAttribute and settings.dataTemplate. The settings.dataTemplate object outlines the data points we need to populate. The settings.dataAttribute string contains the attribute name our function will look at when associating items in the object with elements in our markup. If you refer to the markup above you'll see that each of the item keys in the settings.dataTemplate object corresponds to a span with that string in its 'itemprop' attribute. These same strings will appear in our URL templates.

'dataAttribute': "itemprop",
'dataTemplate': {
    "streetAddress": null,
    "addressLocality": null,
    "addressRegion": null,
    "postalCode": null,
    "addressCountry": null
}

To start the function we first declare our variables. Then we make a copy of the settings.dataTemplate object and store it in a new variable called schemaData. To populate the schemaData object with information from our markup we iterate over the object (schemaData) and for each key (k) create a query. We use the query to find the element in our markup and copy its text into the value (schemaData[k]) of the current key. Once we've been over the entire object we return the now populated schemaData.

"getSchemaData": function(el) {
    var schemaData, k, q;
    //get the data object template from settings
    schemaData = settings.dataTemplate;
    //iterate over the template object
    //to get the matching content from each DOM element
    for (k in schemaData) {
        if (schemaData.hasOwnProperty(k)) {
            //create a query that finds an element with an attribute
            //equal to our current object key
            q = "[" + settings.dataAttribute + "='" + k + "']";
            //using that query look in our parent element and
            //save the text content to our schemaData object
            schemaData[k] = el.find(q).text();
        }
    }
    return schemaData;
}

What would be returned? In our example it's this:

{
    "streetAddress": "894 Granville Street",
    "addressLocality": "Vancouver",
    "addressRegion": "BC",
    "postalCode": "V6Z 1K3",
    "addressCountry": "Canada"
}

getDeviceType

Now that we have our location data we need to know which type of device we're dealing with. To do that we'll inspect the user-agent string. Currently the plugin has specific support for iOS, Android, WP7 and Blackberry and a default behavior for desktop browsers and any unrecognized devices.

Again, we'll look at one of the settings before looking at the function. In this case, the deviceMap array. This array maps a series of strings (qString) found in user-agent strings to a device type (dType). Each device type is in turn associated with an item in settings.urlTemplates.

'deviceMap': [
    {
    "dType": "ios",
    "qString": "ipad"},
    {
    "dType": "ios",
    "qString": "ipod"},
    {
    "dType": "ios",
    "qString": "iphone"},
    {
    "dType": "android",
    "qString": "android"},
    {
    "dType": "blackberry",
    "qString": "blackberry"},
    {
    "dType": "wp7",
    "qString": "windows phone"}
]

Now the function. After declaring our variables we get the user-agent string. Then we iterate over the deviceMap array and try to find one of the strings (qString) in our user-agent. When one matches, we set the dType variable to the current array item's dType string, exit the loop and return the dType variable. If one does not match the returned string will be 'default' and will refer to our default URL template. In the current setup this would be a link to maps.google.com appropriate for desktop browsers.

"getDeviceType": function() {
    var ua, i, l, dType;
    //get the user agent and convert it to lowercase
    //for easier string matching
    ua = navigator.userAgent.toLowerCase();
    i = 0;
    l = settings.deviceMap.length;
    dType = "default";

    // search the user-agent string for qString and when one
    // is, return the associated device type
    for (i; i < l; i++) {
        if (ua.indexOf(settings.deviceMap[i].qString) >= 0) {
            dType = settings.deviceMap[i].dType;
            break;
        }
    }

    return dType;
}

BuildURL

Now our plugin has both the device type and the location data. All that's left is to build the URL.

By default the plugin contains a set of URL templates, one for each device type as well as a default which is used when a specific device type cannot be determined by the getDeviceType function. They are stored in the settings.urlTemplates object. We will use the deviceType to pick a template and our locationData to populate it. Below are the templates. As you can see each one contains a placeholder for each of our locationData keys (ex: {streetAddress}).

'urlTemplates': {
    "default": "http://maps.google.com?q={streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
    "ios": "http://maps.google.com?saddr=Current Location&daddr={streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
    "android": "geo:{streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
    "wp7": "maps:{streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
    "blackberry": "javascript:blackberry.launch.newMap({'address':{'address1':'{streetAddress}','city':'{addressLocality}','country':'{addressCountry}','stateProvince':'{addressRegion}','zipPostal':'{postalCode}'}});"
}

This is probably our simplest function. After declaring our variables we choose a template from the settings.urlTemplates object using the deviceType as our key. We then populate the template with our location data using a function called JSONStringBuilder, encode the URL and return it.

"buildURL": function(deviceType, locationData) {
    var t, url;
    t = settings.urlTemplates[deviceType];
    url = methods.JSONStringBuilder(t, locationData);
    url = encodeURI(url);
    return url;
}

The JSONStringBuilder function is much like other string building functions you may have seen except that it accepts an object as its second argument and not a series of strings. It iterates over the object (o) replacing placeholders ("{" + k + "}") which match each key (k) with the value of the current item (o[k]). Then the string, a populated version of the template, is returned.

"JSONStringBuilder": function(s, o) {
    var k;
    for (k in o) {
        if (o.hasOwnProperty(k)) {
            s = s.replace("{" + k + "}", o[k]);
        }
    }
    return s;
}

That is the final function in our toolbox. With all these various pieces assembled into a jQuery plugin we are now able to search our HTML document for instances of schema.org's PostalAddress microdata, extract the location, build a URL and wrap the markup in an anchor tag which will open the map application on mobile devices.

Mission accomplished!

Here is the complete jQuery plugin

Also available at jsfiddle.net/johnallan/YkMA2/

// by: John Allan (habaneros.com)
// ver: 0.1
// date: February 2012
// Requirements: jQuery
//
// The purpose of this plugin is to parse schema.org formatted 
// data and wrap it in a link. The default functionality is for PostalAddress data
// and opens a native mapping app for mobile devices or Google Maps for a desktop or
// unknown device.
//
// Using the settings object of the plug you could change the default functionality to
// parse other micro data formats, build other types of URLs or support other devices.
//
// Supported devices for the default map functionality:
// iPhone,iPad,iPod,Android,WP7,BlackBerry,other via default template
// Tested Device:
// iPhone4, Android(?), FF4+
//
// TODO:
// develop a pure JS version to remove reliance on jQuery
// add support for default/existing anchor tag
//
// Default settings:
//
// $("[itemtype='schema.org/PostalAddress']").linkSchemaData({ 
//     'linkTitle': 'Open a map to this location', (string) //the title which appears on the anchor tag (string)
//     'linkClass': 'mapLink', (string) //the class(es) which appear on the anchor tag (string)
//     'openInNewWindow': true, (boolean) //links open in a new window (boolean)
//     'openInSameWindow': true, (boolean) //if (openInNewWindow) all links open in the same new window (boolean)
//     'dataAttribute': "itemprop", (string) //the attribute name for matching the dataTemplate keys to DOM elements
//     'dataTemplate': {}, (object) //each key must match a marker in the urlTemplates and a dataAttribute
//     'urlTemplates': {}, (object) //contains a URL template for each device type
//     'deviceMap' [] (array) //an array of objects which maps user-agent strings to URL templates via device type
// });
(function($) {

    $.fn.linkSchemaData = function(options) {

        var settings = $.extend({
            'linkTitle': 'Open a map to this location',
            'linkClass': 'mapLink',
            'openInNewWindow': true,
            'openInSameWindow': true,
            'dataAttribute': "itemprop",
            'dataTemplate': {
                "streetAddress": null,
                "addressLocality": null,
                "addressRegion": null,
                "postalCode": null,
                "addressCountry": null
            },
            'urlTemplates': {
                "default": "http://maps.google.com?q={streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
                "ios": "http://maps.google.com?saddr=Current Location&daddr={streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
                "android": "geo:{streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
                "wp7": "maps:{streetAddress} {addressLocality} {addressRegion} {postalCode} {addressCountry}",
                "blackberry": "javascript:blackberry.launch.newMap({'address':{'address1':'{streetAddress}','city':'{addressLocality}','country':'{addressCountry}','stateProvince':'{addressRegion}','zipPostal':'{postalCode}'}});"
            },
            'deviceMap': [
                {
                "dType": "ios",
                "qString": "ipad"},
            {
                "dType": "ios",
                "qString": "ipod"},
            {
                "dType": "ios",
                "qString": "iphone"},
            {
                "dType": "android",
                "qString": "android"},
            {
                "dType": "blackberry",
                "qString": "blackberry"},
            {
                "dType": "wp7",
                "qString": "windows phone"}]

        }, options);

        var methods = {
            //iterates over a JSON ojbect and replaces each marker in the string template
            //with the matching value from the object
            //{key} in string is replaced with the value of "key" from the object
            "JSONStringBuilder": function(s, o) {
                var k;
                for (k in o) {
                    if (o.hasOwnProperty(k)) {
                        s = s.replace("{" + k + "}", o[k]);
                    }
                }
                return s;
            },
            // using the device type and schema data this function chooses a 
            // template, contructs the URL and encodes it
            "buildURL": function(deviceType, locationData) {
                var t, url;
                t = settings.urlTemplates[deviceType];
                url = methods.JSONStringBuilder(t, locationData);
                url = encodeURI(url);
                return url;
            },
            // extracts the schema data from the given element
            "getSchemaData": function(el) {
                var schemaData, k;
                //get the data object template from settings
                schemaData = settings.dataTemplate;
                //iterate over the object and get the matching content from each DOM element
                //object key, template marker and dataAttribute value must all match
                for (k in schemaData) {
                    if (schemaData.hasOwnProperty(k)) {
                        schemaData[k] = el.find("[" + settings.dataAttribute + "='" + k + "']").text();
                    }
                }
                return schemaData;
            },
            // by parsing the user-agent string this function attempts to determine 
            // which type of platform the user is browsing on
            // if a supported platform is not detected it returns 'default'
            // some variations use the same template (ipad,iphone,ipod == ios)
            "getDeviceType": function() {
                var ua, uaArray, i, l, dType;
                ua = navigator.userAgent.toLowerCase();
                uaArray = settings.deviceMap;

                i = 0;
                l = uaArray.length;
                dType = "default";

                // search the user-agent string for qString and when one is found
                // return the associated device type
                for (i; i < l; i++) {
                    if (ua.indexOf(uaArray[i].qString) >= 0) {
                        dType = uaArray[i].dType;
                        break;
                    }
                }

                return dType;
            }
        };

        return this.each(function() {
            var a, href, schemaData, deviceType;

            //pull schema data from the HTML
            schemaData = methods.getSchemaData($(this));
            //determine the device type by user-agent
            deviceType = methods.getDeviceType();
            //build the URL using the location data and device type
            href = methods.buildURL(deviceType, schemaData);
            //create the anchor tag
            a = $("").attr({
                "href": href,
                "title": settings.linkTitle,
                "class": settings.linkClass,
                "target": (settings.openInNewWindow) ? (settings.openInSameWindow) ? "mapLinkWindow" : "_blank" : "_self"
            });
            //inject the anchor tag into the DOM
            $(this).wrapInner(a);

        });

    };
})(jQuery);

$("[itemtype='schema.org/PostalAddress']").linkSchemaData();?

Stories say it best.

Are you ready to make your workplace awesome? We're keen to hear what you have in mind.

Interested in learning more about the work we do?

Explore our culture and transformation services.

Our commitment to reconciliation

Learn how Habanero is responding to the Truth and Reconciliation Calls to Action as a settler-owned company operating on Indigenous territories across what is now called Canada.

Read about our commitment