Repository: garmeeh/next-seo
Pull request #10 (2018-09-09 17:01:53)

Add Product JSON-LD (V1.2.0)


Patch:
diff --git a/.travis.yml b/.travis.yml
index 9307afc..170e8ca 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -22,7 +22,7 @@ jobs:
       - npm run validate
       - stage: test
       script:
-      - ls
+      - npm run test
       - cd e2e
       - npm ci
       - npm run build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bff3f9b..7bbb6ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
 
 All notable changes to this project will be documented in this file.
 
+## v1.2.0 - 09-09-2018
+
+### Added
+
+- Product JSON-LD [More Info](https://developers.google.com/search/docs/data-types/product)
+- Run Jest tests on Travis
+
+### Fixed
+
+- Duplicate locale tags being returned from buildTags function
+- Updated tests to better check for regressions
+- Snapshot tests not running 100% correctly
+
 ## v1.1.2 - 03-08-2018
 
 ### Added
diff --git a/README.md b/README.md
index 5edf60a..84a8e17 100644
--- a/README.md
+++ b/README.md
@@ -392,6 +392,81 @@ export default () => (
 );
 ```
 
+### Product
+
+```jsx
+import React from 'react';
+import { ProductJsonLd } from 'next-seo';
+
+export default () => (
+  <>
+    <h1>Product JSON-LD</h1>
+    <ProductJsonLd
+      productName="Executive Anvil"
+      images={[
+        'https://example.com/photos/1x1/photo.jpg',
+        'https://example.com/photos/4x3/photo.jpg',
+        'https://example.com/photos/16x9/photo.jpg',
+      ]}
+      description="Sleeker than ACME's Classic Anvil, the Executive Anvil is perfect for the business traveler looking for something to drop from a height."
+      brand="ACME"
+      reviews={[
+        {
+          author: 'Jim',
+          datePublished: '2017-01-06T03:37:40Z',
+          reviewBody:
+            'This is my favorite product yet! Thanks Nate for the example products and reviews.',
+          name: 'So awesome!!!',
+          reviewRating: {
+            bestRating: '5',
+            ratingValue: '5',
+            worstRating: '1',
+          },
+        },
+      ]}
+      aggregateRating={{
+        ratingValue: '4.4',
+        reviewCount: '89',
+      }}
+      offers={{
+        price: '119.99',
+        priceCurrency: 'USD',
+        priceValidUntil: '2020-11-05',
+        itemCondition: 'http://schema.org/UsedCondition',
+        availability: 'http://schema.org/InStock',
+        seller: {
+          name: 'Executive Objects',
+        },
+      }}
+      mpn="925872"
+    />
+  </>
+);
+```
+
+Also available: `sku`, `gtin8`, `gtin13`, `gtin14`.
+
+Valid values for `offers.itemCondition`:
+
+- https://schema.org/DamagedCondition
+- https://schema.org/NewCondition
+- https://schema.org/RefurbishedCondition
+- https://schema.org/UsedCondition
+
+Valid values fro `offers.availability`:
+
+- https://schema.org/Discontinued
+- https://schema.org/InStock
+- https://schema.org/InStoreOnly
+- https://schema.org/LimitedAvailability
+- https://schema.org/OnlineOnly
+- https://schema.org/OutOfStock
+- https://schema.org/PreOrder
+- https://schema.org/PreSale
+- https://schema.org/SoldOut
+
+More info on the product data type can be found [here](https://developers.google.com/search/docs/data-types/product).
+
 ## CHANGELOG
 
 You can view the CHANGELOG [here](CHANGELOG.md)
diff --git a/dist/index.js b/dist/index.js
index 449be22..b52a328 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -3,7 +3,7 @@
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-exports.CourseJsonLd = exports.BlogJsonLd = exports.ArticleJsonLd = undefined;
+exports.ProductJsonLd = exports.CourseJsonLd = exports.BlogJsonLd = exports.ArticleJsonLd = undefined;
 
 var _defaultSEO = require('./meta/defaultSEO');
 
@@ -21,9 +21,14 @@ var _course = require('./jsonld/course');
 
 var _course2 = _interopRequireDefault(_course);
 
+var _product = require('./jsonld/product');
+
+var _product2 = _interopRequireDefault(_product);
+
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 exports.default = _defaultSEO2.default;
 exports.ArticleJsonLd = _article2.default;
 exports.BlogJsonLd = _blog2.default;
-exports.CourseJsonLd = _course2.default;
\ No newline at end of file
+exports.CourseJsonLd = _course2.default;
+exports.ProductJsonLd = _product2.default;
\ No newline at end of file
diff --git a/dist/jsonld/product.js b/dist/jsonld/product.js
new file mode 100644
index 0000000..1a953ef
--- /dev/null
+++ b/dist/jsonld/product.js
@@ -0,0 +1,135 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+
+var _react = require('react');
+
+var _react2 = _interopRequireDefault(_react);
+
+var _propTypes = require('prop-types');
+
+var _propTypes2 = _interopRequireDefault(_propTypes);
+
+var _head = require('next/head');
+
+var _head2 = _interopRequireDefault(_head);
+
+var _markup = require('../utils/markup');
+
+var _markup2 = _interopRequireDefault(_markup);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+var buildImages = function buildImages(images) {
+  return images.length ? '"image": [' + images.map(function (image) {
+    return '"' + image + '"';
+  }) + '],' : '';
+};
+
+var buildBrand = function buildBrand(brand) {
+  return '\n  "brand": {\n      "@type": "Thing",\n      "name": "' + brand + '"\n    },\n';
+};
+
+var buildReviewRating = function buildReviewRating(rating) {
+  return rating ? '"reviewRating": {\n          "@type": "Rating",\n          ' + (rating.bestRating ? '"bestRating": "' + rating.bestRating + '",' : '') + '\n          ' + (rating.worstRating ? '"worstRating": "' + rating.worstRating + '",' : '') + '\n          "ratingValue": "' + rating.ratingValue + '"\n        },' : '';
+};
+
+var buildReviews = function buildReviews(reviews) {
+  return '\n"review": [\n  ' + reviews.map(function (review) {
+    return '{\n      "@type": "Review",\n      ' + (review.datePublished ? '"datePublished": "' + review.datePublished + '",' : '') + '\n      ' + (review.reviewBody ? '"reviewBody": "' + review.reviewBody + '",' : '') + '\n      ' + (review.name ? '"name": "' + review.name + '",' : '') + '\n      ' + buildReviewRating(review.reviewRating) + '\n      "author": "' + review.author + '"\n  }';
+  }) + '],';
+};
+
+var buildAggregateRating = function buildAggregateRating(aggregateRating) {
+  return '\n  "aggregateRating": {\n      "@type": "AggregateRating",\n      "ratingValue": "' + aggregateRating.ratingValue + '",\n      "reviewCount": "' + aggregateRating.reviewCount + '"\n    },\n';
+};
+
+// TODO: Docs for offers itemCondition & availability
+// TODO: Seller type, make dynamic
+var buildOffers = function buildOffers(offers) {
+  return '\n  "offers": {\n    "@type": "Offer",\n    "priceCurrency": "' + offers.priceCurrency + '",\n    ' + (offers.priceValidUntil ? '"priceValidUntil": "' + offers.priceValidUntil + '",' : '') + '\n    ' + (offers.itemCondition ? '"itemCondition": "' + offers.itemCondition + '",' : '') + '\n    ' + (offers.availability ? '"availability": "' + offers.availability + '",' : '') + '\n    ' + (offers.seller ? '\n      "seller": {\n      "@type": "Organization",\n      "name": "' + offers.seller.name + '"\n    },\n    ' : '') + '\n    "price": "' + offers.price + '"\n  },\n';
+};
+
+var ProductJsonLd = function ProductJsonLd(_ref) {
+  var productName = _ref.productName,
+      _ref$images = _ref.images,
+      images = _ref$images === undefined ? [] : _ref$images,
+      description = _ref.description,
+      sku = _ref.sku,
+      gtin8 = _ref.gtin8,
+      gtin13 = _ref.gtin13,
+      gtin14 = _ref.gtin14,
+      mpn = _ref.mpn,
+      brand = _ref.brand,
+      _ref$reviews = _ref.reviews,
+      reviews = _ref$reviews === undefined ? [] : _ref$reviews,
+      aggregateRating = _ref.aggregateRating,
+      offers = _ref.offers;
+
+  var jslonld = '{\n    "@context": "http://schema.org/",\n    "@type": "Product",\n    ' + buildImages(images) + '\n    ' + (description ? '"description": "' + description + '",' : '') + '\n    ' + (mpn ? '"mpn": "' + mpn + '",' : '') + '\n    ' + (sku ? '"sku": "' + sku + '",' : '') + '\n    ' + (gtin8 ? '"gtin8": "' + gtin8 + '",' : '') + '\n    ' + (gtin13 ? '"gtin13": "' + gtin13 + '",' : '') + '\n    ' + (gtin14 ? '"gtin14": "' + gtin14 + '",' : '') + '\n    ' + (brand ? buildBrand(brand) : '') + '\n    ' + (reviews.length ? buildReviews(reviews) : '') + '\n    ' + (aggregateRating ? buildAggregateRating(aggregateRating) : '') + '\n    ' + (offers ? buildOffers(offers) : '') + '\n    "name": "' + productName + '"\n  }';
+
+  return _react2.default.createElement(
+    _head2.default,
+    null,
+    _react2.default.createElement('script', {
+      type: 'application/ld+json',
+      dangerouslySetInnerHTML: (0, _markup2.default)(jslonld),
+      key: 'jsonld-product'
+    })
+  );
+};
+
+ProductJsonLd.defaultProps = {
+  images: [],
+  description: null,
+  brand: null,
+  reviews: [],
+  aggregateRating: null,
+  offers: null,
+  sku: null,
+  gtin8: null,
+  gtin13: null,
+  gtin14: null,
+  mpn: null
+};
+
+ProductJsonLd.propTypes = {
+  productName: _propTypes2.default.string.isRequired,
+  images: _propTypes2.default.arrayOf(_propTypes2.default.string),
+  description: _propTypes2.default.string,
+  brand: _propTypes2.default.string,
+  reviews: _propTypes2.default.arrayOf(_propTypes2.default.shape({
+    author: _propTypes2.default.string.isRequired,
+    datePublished: _propTypes2.default.string,
+    reviewBody: _propTypes2.default.string,
+    name: _propTypes2.default.string,
+    reviewRating: _propTypes2.default.shape({
+      bestRating: _propTypes2.default.string,
+      ratingValue: _propTypes2.default.string.isRequired,
+      worstRating: _propTypes2.default.string
+    })
+  })),
+  aggregateRating: _propTypes2.default.shape({
+    ratingValue: _propTypes2.default.string.isRequired,
+    reviewCount: _propTypes2.default.string.isRequired
+  }),
+  offers: _propTypes2.default.shape({
+    price: _propTypes2.default.string.isRequired,
+    priceCurrency: _propTypes2.default.string.isRequired,
+    priceValidUntil: _propTypes2.default.string,
+    itemCondition: _propTypes2.default.string,
+    availability: _propTypes2.default.string,
+    seller: _propTypes2.default.shape({
+      name: _propTypes2.default.string.isRequired
+    })
+  }),
+  sku: _propTypes2.default.string,
+  gtin8: _propTypes2.default.string,
+  gtin13: _propTypes2.default.string,
+  gtin14: _propTypes2.default.string,
+  mpn: _propTypes2.default.string
+};
+
+exports.default = ProductJsonLd;
\ No newline at end of file
diff --git a/dist/meta/buildTags.js b/dist/meta/buildTags.js
index b96adff..c7ca2fa 100644
--- a/dist/meta/buildTags.js
+++ b/dist/meta/buildTags.js
@@ -134,14 +134,6 @@ var buildTags = function buildTags(config) {
       tagsToRender.push(_react2.default.createElement("meta", { key: "og:locale", property: "og:locale", content: config.openGraph.locale }));
     }
 
-    if (config.openGraph.locale) {
-      tagsToRender.push(_react2.default.createElement("meta", { key: "og:locale", property: "og:locale", content: config.openGraph.locale }));
-    }
-
-    if (config.openGraph.locale) {
-      tagsToRender.push(_react2.default.createElement("meta", { key: "og:locale", property: "og:locale", content: config.openGraph.locale }));
-    }
-
     if (config.openGraph.site_name) {
       tagsToRender.push(_react2.default.createElement("meta", { key: "og:site_name", property: "og:site_name", content: config.openGraph.site_name }));
     }
diff --git a/jest.config.js b/jest.config.js
index 413bde6..762a21e 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,3 +1,8 @@
 module.exports = {
-  testPathIgnorePatterns: ['<rootDir>/.history/', '<rootDir>/node_modules/', '<rootDir>/e2e/'],
+  testPathIgnorePatterns: [
+    '<rootDir>/.history/',
+    '<rootDir>/node_modules/',
+    '<rootDir>/e2e/',
+    '<rootDir>/dist/',
+  ],
 };
diff --git a/package.json b/package.json
index e360270..ef4c5f2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "next-seo",
-  "version": "1.1.2",
+  "version": "1.2.0",
   "description": "SEO plugin for Next.js projects",
   "main": "dist/index.js",
   "files": [
@@ -17,7 +17,7 @@
     "test:watch": "jest --watch",
     "prebuild": "rimraf build",
     "lint": "eslint src",
-    "build": "babel --copy-files --out-dir dist --ignore *.spec.js src",
+    "build": "babel --copy-files --out-dir dist --ignore *.spec.js,*.snap src",
     "validate": "npm-run-all --parallel lint build"
   },
   "prettier": {
diff --git a/src/index.js b/src/index.js
index 7528993..65642fe 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,6 +2,9 @@ import DefaultSeo from './meta/defaultSEO';
 import ArticleJsonLd from './jsonld/article';
 import BlogJsonLd from './jsonld/blog';
 import CourseJsonLd from './jsonld/course';
+import ProductJsonLd from './jsonld/product';
 
 export default DefaultSeo;
-export { ArticleJsonLd, BlogJsonLd, CourseJsonLd };
+export {
+  ArticleJsonLd, BlogJsonLd, CourseJsonLd, ProductJsonLd,
+};
diff --git a/src/jsonld/product.jsx b/src/jsonld/product.jsx
new file mode 100644
index 0000000..558363b
--- /dev/null
+++ b/src/jsonld/product.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Head from 'next/head';
+
+import markup from '../utils/markup';
+
+const buildImages = images => (images.length ? `"image": [${images.map(image => `"${image}"`)}],` : '');
+
+const buildBrand = brand => `
+  "brand": {
+      "@type": "Thing",
+      "name": "${brand}"
+    },
+`;
+
+const buildReviewRating = rating => (rating
+  ? `"reviewRating": {
+          "@type": "Rating",
+          ${rating.bestRating ? `"bestRating": "${rating.bestRating}",` : ''}
+          ${rating.worstRating ? `"worstRating": "${rating.worstRating}",` : ''}
+          "ratingValue": "${rating.ratingValue}"
+        },`
+  : '');
+
+const buildReviews = reviews => `
+"review": [
+  ${reviews.map(
+    review => `{
+      "@type": "Review",
+      ${review.datePublished ? `"datePublished": "${review.datePublished}",` : ''}
+      ${review.reviewBody ? `"reviewBody": "${review.reviewBody}",` : ''}
+      ${review.name ? `"name": "${review.name}",` : ''}
+      ${buildReviewRating(review.reviewRating)}
+      "author": "${review.author}"
+  }`,
+  )}],`;
+
+const buildAggregateRating = aggregateRating => `
+  "aggregateRating": {
+      "@type": "AggregateRating",
+      "ratingValue": "${aggregateRating.ratingValue}",
+      "reviewCount": "${aggregateRating.reviewCount}"
+    },
+`;
+
+// TODO: Docs for offers itemCondition & availability
+// TODO: Seller type, make dynamic
+const buildOffers = offers => `
+  "offers": {
+    "@type": "Offer",
+    "priceCurrency": "${offers.priceCurrency}",
+    ${offers.priceValidUntil ? `"priceValidUntil": "${offers.priceValidUntil}",` : ''}
+    ${offers.itemCondition ? `"itemCondition": "${offers.itemCondition}",` : ''}
+    ${offers.availability ? `"availability": "${offers.availability}",` : ''}
+    ${
+  offers.seller
+    ? `
+      "seller": {
+      "@type": "Organization",
+      "name": "${offers.seller.name}"
+    },
+    `
+    : ''
+}
+    "price": "${offers.price}"
+  },
+`;
+
+const ProductJsonLd = ({
+  productName,
+  images = [],
+  description,
+  sku,
+  gtin8,
+  gtin13,
+  gtin14,
+  mpn,
+  brand,
+  reviews = [],
+  aggregateRating,
+  offers,
+}) => {
+  const jslonld = `{
+    "@context": "http://schema.org/",
+    "@type": "Product",
+    ${buildImages(images)}
+    ${description ? `"description": "${description}",` : ''}
+    ${mpn ? `"mpn": "${mpn}",` : ''}
+    ${sku ? `"sku": "${sku}",` : ''}
+    ${gtin8 ? `"gtin8": "${gtin8}",` : ''}
+    ${gtin13 ? `"gtin13": "${gtin13}",` : ''}
+    ${gtin14 ? `"gtin14": "${gtin14}",` : ''}
+    ${brand ? buildBrand(brand) : ''}
+    ${reviews.length ? buildReviews(reviews) : ''}
+    ${aggregateRating ? buildAggregateRating(aggregateRating) : ''}
+    ${offers ? buildOffers(offers) : ''}
+    "name": "${productName}"
+  }`;
+
+  return (
+    <Head>
+      <script
+        type="application/ld+json"
+        dangerouslySetInnerHTML={markup(jslonld)}
+        key="jsonld-product"
+      />
+    </Head>
+  );
+};
+
+ProductJsonLd.defaultProps = {
+  images: [],
+  description: null,
+  brand: null,
+  reviews: [],
+  aggregateRating: null,
+  offers: null,
+  sku: null,
+  gtin8: null,
+  gtin13: null,
+  gtin14: null,
+  mpn: null,
+};
+
+ProductJsonLd.propTypes = {
+  productName: PropTypes.string.isRequired,
+  images: PropTypes.arrayOf(PropTypes.string),
+  description: PropTypes.string,
+  brand: PropTypes.string,
+  reviews: PropTypes.arrayOf(
+    PropTypes.shape({
+      author: PropTypes.string.isRequired,
+      datePublished: PropTypes.string,
+      reviewBody: PropTypes.string,
+      name: PropTypes.string,
+      reviewRating: PropTypes.shape({
+        bestRating: PropTypes.string,
+        ratingValue: PropTypes.string.isRequired,
+        worstRating: PropTypes.string,
+      }),
+    }),
+  ),
+  aggregateRating: PropTypes.shape({
+    ratingValue: PropTypes.string.isRequired,
+    reviewCount: PropTypes.string.isRequired,
+  }),
+  offers: PropTypes.shape({
+    price: PropTypes.string.isRequired,
+    priceCurrency: PropTypes.string.isRequired,
+    priceValidUntil: PropTypes.string,
+    itemCondition: PropTypes.string,
+    availability: PropTypes.string,
+    seller: PropTypes.shape({
+      name: PropTypes.string.isRequired,
+    }),
+  }),
+  sku: PropTypes.string,
+  gtin8: PropTypes.string,
+  gtin13: PropTypes.string,
+  gtin14: PropTypes.string,
+  mpn: PropTypes.string,
+};
+
+export default ProductJsonLd;
diff --git a/src/meta/buildTags.jsx b/src/meta/buildTags.jsx
index 691bdc7..45f38e4 100644
--- a/src/meta/buildTags.jsx
+++ b/src/meta/buildTags.jsx
@@ -142,18 +142,6 @@ const buildTags = (config) => {
       );
     }
 
-    if (config.openGraph.locale) {
-      tagsToRender.push(
-        <meta key="og:locale" property="og:locale" content={config.openGraph.locale} />,
-      );
-    }
-
-    if (config.openGraph.locale) {
-      tagsToRender.push(
-        <meta key="og:locale" property="og:locale" content={config.openGraph.locale} />,
-      );
-    }
-
     if (config.openGraph.site_name) {
       tagsToRender.push(
         <meta key="og:site_name" property="og:site_name" content={config.openGraph.site_name} />,


Test patch:
diff --git a/dist/meta/__tests__/__snapshots__/buildTags.test.jsx.snap b/dist/meta/__tests__/__snapshots__/buildTags.test.jsx.snap
deleted file mode 100644
index eb6b8d9..0000000
--- a/dist/meta/__tests__/__snapshots__/buildTags.test.jsx.snap
+++ /dev/null
@@ -1,109 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders correctly 1`] = `
-<div>
-  <title>
-    This is a test title.
-  </title>
-  <meta
-    content="index,follow"
-    name="robots"
-  />
-  <meta
-    content="index,follow"
-    name="googlebot"
-  />
-  <meta
-    content="This is a test description."
-    name="description"
-  />
-  <meta
-    content="summary_large_image"
-    name="twitter:card"
-  />
-  <meta
-    content="@site"
-    name="twitter:site"
-  />
-  <meta
-    content="@handle"
-    name="twitter:creator"
-  />
-  <meta
-    content="https://www.url.ie"
-    property="og:url"
-  />
-  <meta
-    content="website"
-    property="og:type"
-  />
-  <meta
-    content="Open graph title"
-    property="og:title"
-  />
-  <meta
-    content="This is testing og:description."
-    property="og:description"
-  />
-  <meta
-    content="https://www.test.ie/image-01.jpg"
-    property="og:image"
-  />
-  <meta
-    content="Alt text right here"
-    property="og:image:alt"
-  />
-  <meta
-    content="800"
-    property="og:image:width"
-  />
-  <meta
-    content="600"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-02.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-03.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-04.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="en_IE"
-    property="og:locale"
-  />
-  <link
-    href="https://www.canonical.ie"
-    rel="canonical"
-  />
-</div>
-`;
diff --git a/dist/meta/__tests__/buildTags.test.js b/dist/meta/__tests__/buildTags.test.js
deleted file mode 100644
index e9d4104..0000000
--- a/dist/meta/__tests__/buildTags.test.js
+++ /dev/null
@@ -1,174 +0,0 @@
-'use strict';
-
-var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
-
-var _reactTestingLibrary = require('react-testing-library');
-
-var _react = require('react');
-
-var _react2 = _interopRequireDefault(_react);
-
-var _buildTags = require('../buildTags');
-
-var _buildTags2 = _interopRequireDefault(_buildTags);
-
-function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
-
-var SEO = {
-  title: 'This is a test title.',
-  description: 'This is a test description.',
-  canonical: 'https://www.canonical.ie',
-  openGraph: {
-    type: 'website',
-    locale: 'en_IE',
-    url: 'https://www.url.ie',
-    title: 'Open graph title',
-    description: 'This is testing og:description.',
-    defaultImageWidth: 1200,
-    defaultImageHeight: 1200,
-    images: [{
-      url: 'https://www.test.ie/image-01.jpg',
-      width: 800,
-      height: 600,
-      alt: 'Alt text right here'
-    }, { url: 'https://www.test.ie/image-02.jpg' }, { url: 'https://www.test.ie/image-03.jpg' }, { url: 'https://www.test.ie/image-04.jpg' }],
-    site_name: 'SiteName'
-  },
-  twitter: {
-    handle: '@handle',
-    site: '@site',
-    cardType: 'summary_large_image'
-  }
-};
-
-it('renders correctly', function () {
-  var tags = (0, _buildTags2.default)(SEO);
-
-  var _render = (0, _reactTestingLibrary.render)(tags),
-      container = _render.container;
-
-  expect(container).toMatchSnapshot();
-});
-
-/* eslint no-magic-numbers: 0 */
-it('returns full array for default seo object', function () {
-  var tags = (0, _buildTags2.default)(SEO);
-
-  var _render2 = (0, _reactTestingLibrary.render)(tags),
-      container = _render2.container;
-
-  var title = (0, _reactTestingLibrary.getByText)(container, function (content, element) {
-    return element.tagName.toLowerCase() === 'title' && content.startsWith('' + SEO.title);
-  });
-  var index = container.querySelectorAll('meta[content="index,follow"]');
-  var description = container.querySelectorAll('meta[content="' + SEO.description + '"]');
-  var descriptionTag = container.querySelectorAll('meta[name="description"]');
-  var twitterCard = container.querySelectorAll('meta[content="summary_large_image"]');
-  var twitterCardTag = container.querySelectorAll('meta[name="twitter:card"]');
-  var twitterHandle = container.querySelectorAll('meta[content="' + SEO.twitter.handle + '"]');
-  var twitterHandleTag = container.querySelectorAll('meta[name="twitter:creator"]');
-  var twitterSite = container.querySelectorAll('meta[content="' + SEO.twitter.site + '"]');
-  var twitterSiteTag = container.querySelectorAll('meta[name="twitter:site"]');
-  var ogUrl = container.querySelectorAll('meta[content="' + SEO.openGraph.url + '"]');
-  var ogUrlTag = container.querySelectorAll('meta[property="og:url"]');
-  var ogType = container.querySelectorAll('meta[content="' + SEO.openGraph.type + '"]');
-  var ogTypeTag = container.querySelectorAll('meta[property="og:type"]');
-  var ogTitle = container.querySelectorAll('meta[content="' + SEO.openGraph.title + '"]');
-  var ogTitleTag = container.querySelectorAll('meta[property="og:title"]');
-  var ogDescription = container.querySelectorAll('meta[content="' + SEO.openGraph.description + '"]');
-  var ogDescriptionTag = container.querySelectorAll('meta[property="og:description"]');
-  var ogImage00 = container.querySelectorAll('meta[content="' + SEO.openGraph.images[0].url + '"]');
-  var ogImageTag00 = tags.filter(function (item) {
-    return item.key === 'og:image:01';
-  });
-  var ogImage01 = container.querySelectorAll('meta[content="' + SEO.openGraph.images[1].url + '"]');
-  var ogImageTag01 = tags.filter(function (item) {
-    return item.key === 'og:image:01';
-  });
-  var ogImage02 = container.querySelectorAll('meta[content="' + SEO.openGraph.images[2].url + '"]');
-  var ogImageTag02 = tags.filter(function (item) {
-    return item.key === 'og:image:02';
-  });
-  var ogImage03 = container.querySelectorAll('meta[content="' + SEO.openGraph.images[3].url + '"]');
-  var ogImageTag03 = tags.filter(function (item) {
-    return item.key === 'og:image:03';
-  });
-  var ogDefaultImageWidthHeight = container.querySelectorAll('meta[content="' + SEO.openGraph.defaultImageHeight + '"]');
-  var ogSetImageHeight = container.querySelectorAll('meta[content="' + SEO.openGraph.images[0].height + '"]');
-  var ogSetImageWidth = container.querySelectorAll('meta[content="' + SEO.openGraph.images[0].width + '"]');
-  var ogSetImageAlt = container.querySelectorAll('meta[content="' + SEO.openGraph.images[0].alt + '"]');
-  var ogLocale = container.querySelectorAll('meta[content="' + SEO.openGraph.locale + '"]');
-  var ogLocaleTag = tags.filter(function (item) {
-    return item.key === 'og:locale';
-  });
-  var canonicalTag = tags.filter(function (item) {
-    return item.key === 'canonical';
-  });
-
-  expect(title).toBeDefined();
-  expect(Array.from(index).length).toBe(2);
-  expect(Array.from(description).length).toBe(1);
-  expect(Array.from(descriptionTag).length).toBe(1);
-  expect(Array.from(twitterCard).length).toBe(1);
-  expect(Array.from(twitterCardTag).length).toBe(1);
-  expect(Array.from(twitterHandle).length).toBe(1);
-  expect(Array.from(twitterHandleTag).length).toBe(1);
-  expect(Array.from(twitterSite).length).toBe(1);
-  expect(Array.from(twitterSiteTag).length).toBe(1);
-  expect(Array.from(ogUrl).length).toBe(1);
-  expect(Array.from(ogUrlTag).length).toBe(1);
-  expect(Array.from(ogType).length).toBe(1);
-  expect(Array.from(ogTypeTag).length).toBe(1);
-  expect(Array.from(ogTitle).length).toBe(1);
-  expect(Array.from(ogTitleTag).length).toBe(1);
-  expect(Array.from(ogDescription).length).toBe(1);
-  expect(Array.from(ogDescriptionTag).length).toBe(1);
-  expect(Array.from(ogImage00).length).toBe(1);
-  expect(Array.from(ogImageTag00).length).toBe(1);
-  expect(Array.from(ogImage01).length).toBe(1);
-  expect(Array.from(ogImageTag01).length).toBe(1);
-  expect(Array.from(ogImage02).length).toBe(1);
-  expect(Array.from(ogImageTag02).length).toBe(1);
-  expect(Array.from(ogImage03).length).toBe(1);
-  expect(Array.from(ogImageTag03).length).toBe(1);
-  expect(Array.from(ogDefaultImageWidthHeight).length).toBe(6);
-  expect(Array.from(ogSetImageHeight).length).toBe(1);
-  expect(Array.from(ogSetImageWidth).length).toBe(1);
-  expect(Array.from(ogSetImageAlt).length).toBe(1);
-  expect(Array.from(ogLocale).length).toBe(1);
-  expect(Array.from(ogLocaleTag).length).toBe(1);
-  expect(canonicalTag[0].props.href).toBe('' + SEO.canonical);
-  expect(Array.from(canonicalTag).length).toBe(1);
-});
-
-it('correctly sets noindex, nofollow', function () {
-  var overrideProps = _extends({}, SEO, {
-    noindex: true
-  });
-  var tags = (0, _buildTags2.default)(overrideProps);
-
-  var _render3 = (0, _reactTestingLibrary.render)(tags),
-      container = _render3.container;
-
-  var index = container.querySelectorAll('meta[content="index,follow"]');
-  var noindex = container.querySelectorAll('meta[content="noindex,nofollow"]');
-
-  expect(Array.from(index).length).toBe(0);
-  expect(Array.from(noindex).length).toBe(2);
-});
-
-it('displays title with titleTemplate integrated', function () {
-  var template = 'Next SEO';
-  var overrideProps = _extends({}, SEO, {
-    titleTemplate: template + ' | %s'
-  });
-  var tags = (0, _buildTags2.default)(overrideProps);
-
-  var _render4 = (0, _reactTestingLibrary.render)(tags),
-      container = _render4.container;
-
-  var title = (0, _reactTestingLibrary.getByText)(container, function (content, element) {
-    return element.tagName.toLowerCase() === 'title' && content.startsWith(template);
-  });
-  expect(title.innerHTML).toMatch(template + ' | ' + SEO.title);
-});
\ No newline at end of file
diff --git a/e2e/README.md b/e2e/README.md
index 846074e..a05a365 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -1,1 +1,4 @@
 App is used for integration tests.
+
+If running tests ensure you have run `npm run validate` at the root of next-seo
+so that tests are running against the latest build
diff --git a/e2e/cypress/e2e/jsonld.spec.js b/e2e/cypress/e2e/jsonld.spec.js
index 98ed168..95a7e26 100644
--- a/e2e/cypress/e2e/jsonld.spec.js
+++ b/e2e/cypress/e2e/jsonld.spec.js
@@ -1,7 +1,7 @@
 import { assertSchema } from '@cypress/schema-tools';
 import schemas from '../schemas';
 
-const expectedJSONResults = 3;
+const expectedJSONResults = 4;
 
 describe('Validates JSON-LD For:', () => {
   it('Article', () => {
@@ -14,6 +14,44 @@ describe('Validates JSON-LD For:', () => {
       });
   });
 
+  it('Article Matches', () => {
+    cy.visit('http://localhost:3000/jsonld');
+    cy.get('head script[type="application/ld+json"]')
+      .should('have.length', expectedJSONResults)
+      .then((tags) => {
+        const jsonLD = JSON.parse(tags[0].innerHTML);
+        expect(jsonLD).to.deep.equal({
+          '@context': 'http://schema.org',
+          '@type': 'Article',
+          mainEntityOfPage: {
+            '@type': 'WebPage',
+            '@id': 'https://example.com/article',
+          },
+          headline: 'Article headline',
+          image: [
+            'https://example.com/photos/1x1/photo.jpg',
+            'https://example.com/photos/4x3/photo.jpg',
+            'https://example.com/photos/16x9/photo.jpg',
+          ],
+          datePublished: '2015-02-05T08:00:00+08:00',
+          dateModified: '2015-02-05T09:00:00+08:00',
+          author: {
+            '@type': 'Person',
+            name: 'Jane Blogs',
+          },
+          publisher: {
+            '@type': 'Organization',
+            name: 'Gary Meehan',
+            logo: {
+              '@type': 'ImageObject',
+              url: 'https://www.example.com/photos/logo.jpg',
+            },
+          },
+          description: 'This is a mighty good description of this article.',
+        });
+      });
+  });
+
   it('Blog', () => {
     cy.visit('http://localhost:3000/jsonld');
     cy.get('head script[type="application/ld+json"]')
@@ -24,6 +62,36 @@ describe('Validates JSON-LD For:', () => {
       });
   });
 
+  it('Blog Matches', () => {
+    cy.visit('http://localhost:3000/jsonld');
+    cy.get('head script[type="application/ld+json"]')
+      .should('have.length', expectedJSONResults)
+      .then((tags) => {
+        const jsonLD = JSON.parse(tags[1].innerHTML);
+        expect(jsonLD).to.deep.equal({
+          '@context': 'http://schema.org',
+          '@type': 'Blog',
+          mainEntityOfPage: {
+            '@type': 'WebPage',
+            '@id': 'https://example.com/blog',
+          },
+          headline: 'Blog headline',
+          image: [
+            'https://example.com/photos/1x1/photo.jpg',
+            'https://example.com/photos/4x3/photo.jpg',
+            'https://example.com/photos/16x9/photo.jpg',
+          ],
+          datePublished: '2015-02-05T08:00:00+08:00',
+          dateModified: '2015-02-05T09:00:00+08:00',
+          author: {
+            '@type': 'Person',
+            name: 'Jane Blogs',
+          },
+          description: 'This is a mighty good description of this blog.',
+        });
+      });
+  });
+
   it('Course', () => {
     cy.visit('http://localhost:3000/jsonld');
     cy.get('head script[type="application/ld+json"]')
@@ -33,4 +101,93 @@ describe('Validates JSON-LD For:', () => {
         assertSchema(schemas)('Course', '1.0.0')(jsonLD);
       });
   });
+
+  it('Course Matches', () => {
+    cy.visit('http://localhost:3000/jsonld');
+    cy.get('head script[type="application/ld+json"]')
+      .should('have.length', expectedJSONResults)
+      .then((tags) => {
+        const jsonLD = JSON.parse(tags[2].innerHTML);
+        expect(jsonLD).to.deep.equal({
+          '@context': 'http://schema.org',
+          '@type': 'Course',
+          name: 'Course Name',
+          description: 'Course description goes right here',
+          provider: {
+            '@type': 'Organization',
+            name: 'Course Provider',
+            sameAs: 'https//www.example.com/provider',
+          },
+        });
+      });
+  });
+
+  it('Product', () => {
+    cy.visit('http://localhost:3000/jsonld');
+    cy.get('head script[type="application/ld+json"]')
+      .should('have.length', expectedJSONResults)
+      .then((tags) => {
+        const jsonLD = JSON.parse(tags[3].innerHTML);
+        assertSchema(schemas)('Product', '1.0.0')(jsonLD);
+      });
+  });
+
+  it('Product Matches', () => {
+    cy.visit('http://localhost:3000/jsonld');
+    cy.get('head script[type="application/ld+json"]')
+      .should('have.length', expectedJSONResults)
+      .then((tags) => {
+        const jsonLD = JSON.parse(tags[3].innerHTML);
+        expect(jsonLD).to.deep.equal({
+          '@context': 'http://schema.org/',
+          '@type': 'Product',
+          name: 'Executive Anvil',
+          image: [
+            'https://example.com/photos/1x1/photo.jpg',
+            'https://example.com/photos/4x3/photo.jpg',
+            'https://example.com/photos/16x9/photo.jpg',
+          ],
+          description:
+            "Sleeker than ACME's Classic Anvil, the Executive Anvil is perfect for the business traveler looking for something to drop from a height.",
+          mpn: '925872',
+          brand: {
+            '@type': 'Thing',
+            name: 'ACME',
+          },
+          review: [
+            {
+              '@type': 'Review',
+              author: 'Jim',
+              datePublished: '2017-01-06T03:37:40Z',
+              reviewBody:
+                'This is my favorite product yet! Thanks Nate for the example products and reviews.',
+              name: 'So awesome!!!',
+              reviewRating: {
+                '@type': 'Rating',
+                bestRating: '5',
+                ratingValue: '5',
+                worstRating: '1',
+              },
+            },
+          ],
+          aggregateRating: {
+            '@type': 'AggregateRating',
+            ratingValue: '4.4',
+            reviewCount: '89',
+          },
+          offers: {
+            '@type': 'Offer',
+            priceCurrency: 'USD',
+            price: '119.99',
+            priceValidUntil: '2020-11-05',
+            itemCondition: 'http://schema.org/UsedCondition',
+            availability: 'http://schema.org/InStock',
+            seller: {
+              '@type': 'Organization',
+              name: 'Executive Objects',
+            },
+          },
+        });
+      });
+  });
 });
diff --git a/e2e/cypress/schemas/common.js b/e2e/cypress/schemas/common.js
new file mode 100644
index 0000000..61c4cfd
--- /dev/null
+++ b/e2e/cypress/schemas/common.js
@@ -0,0 +1,229 @@
+export const brand100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description: 'Brand',
+    properties: {
+      '@type': {
+        type: 'string',
+        description: 'Describes type',
+      },
+      name: {
+        type: 'string',
+        description: 'Brand name',
+      },
+    },
+    example: {
+      '@type': 'Thing',
+      name: 'ACME',
+    },
+  },
+};
+
+export const aggregateRating100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description: 'The average rating based on multiple ratings or reviews.',
+    properties: {
+      '@type': {
+        type: 'string',
+        description: 'Describes type',
+      },
+      ratingValue: {
+        type: 'string',
+        description: 'The rating for the content.',
+      },
+      reviewCount: {
+        type: 'string',
+        description: 'The count of total number of reviews.',
+      },
+    },
+    example: {
+      '@type': 'AggregateRating',
+      ratingValue: '4.4',
+      reviewCount: '89',
+    },
+  },
+};
+
+export const seller100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description:
+      'An entity which offers (sells / leases / lends / loans) the services / goods. A seller may also be a provider. Supersedes merchant, vendor.',
+    properties: {
+      '@type': {
+        type: 'string',
+        description: 'Organisation or Person',
+      },
+      name: {
+        type: 'string',
+        description: 'Seller name',
+      },
+    },
+    required: true,
+    example: {
+      '@type': 'Organization',
+      name: 'Executive Objects',
+    },
+  },
+};
+
+export const offers100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description: 'Offers',
+    properties: {
+      '@type': {
+        type: 'string',
+        description: 'Describes type',
+      },
+      priceCurrency: {
+        type: 'string',
+        description:
+          'Use standard formats: ISO 4217 currency format e.g. "USD"; Ticker symbol for cryptocurrencies e.g. "BTC"; well known names for Local Exchange Tradings Systems (LETS) and other currency types e.g. "Ithaca HOUR".',
+      },
+      price: {
+        type: 'string',
+        description: 'Price.',
+      },
+      priceValidUntil: {
+        type: 'string',
+        description: 'The date after which the price is no longer available.',
+      },
+      itemCondition: {
+        type: 'string',
+        description:
+          'itemCondition OfferItemCondition A predefined value from OfferItemCondition or a textual description of the condition of the product or service, or the products or services included in the offer.',
+      },
+      availability: {
+        type: 'string',
+        description:
+          'The availability of this item—for example In stock, Out of stock, Pre-order, etc.',
+      },
+      seller: {
+        ...seller100.schema,
+        see: seller100,
+      },
+    },
+    example: {
+      '@type': 'Offer',
+      priceCurrency: 'USD',
+      price: '119.99',
+      priceValidUntil: '2020-11-05',
+      itemCondition: 'http://schema.org/UsedCondition',
+      availability: 'http://schema.org/InStock',
+      seller: {
+        '@type': 'Organization',
+        name: 'Executive Objects',
+      },
+    },
+  },
+};
+
+export const rating100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description: 'Rating',
+    properties: {
+      '@type': {
+        type: 'string',
+      },
+      bestRating: {
+        type: 'string',
+        description: 'Highest rating',
+      },
+      ratingValue: {
+        type: 'string',
+        description: 'The rating for the content',
+      },
+      worstRating: {
+        type: 'string',
+        description: 'Lowest rating',
+      },
+    },
+    additionalProperties: false,
+    example: {
+      '@type': 'Rating',
+      bestRating: '5',
+      ratingValue: '5',
+      worstRating: '1',
+    },
+  },
+};
+
+export const review100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    description: 'Review',
+    properties: {
+      '@type': {
+        type: 'string',
+      },
+      author: {
+        type: 'string',
+        description: 'Author of the review',
+      },
+      datePublished: {
+        type: 'string',
+        description: 'Publish data of the review',
+      },
+      reviewBody: {
+        type: 'string',
+        description: 'Main review',
+      },
+      name: {
+        type: 'string',
+        description: 'Title of the review',
+      },
+      reviewRating: {
+        ...rating100.schema,
+        see: rating100,
+      },
+    },
+    additionalProperties: false,
+    example: {
+      '@type': 'Review',
+      author: 'Jim',
+      datePublished: '2017-01-06T03:37:40Z',
+      reviewBody:
+        'This is my favorite product yet! Thanks Nate for the example products and reviews.',
+      name: 'So awesome!!!',
+      reviewRating: {
+        '@type': 'Rating',
+        bestRating: '5',
+        ratingValue: '5',
+        worstRating: '1',
+      },
+    },
+  },
+};
diff --git a/e2e/cypress/schemas/course-schema.js b/e2e/cypress/schemas/course-schema.js
index 6165d24..7e673ea 100644
--- a/e2e/cypress/schemas/course-schema.js
+++ b/e2e/cypress/schemas/course-schema.js
@@ -73,6 +73,7 @@ const course100 = {
     '@context': 'http://schema.org',
     '@type': 'Course',
     name: 'Course Name',
+    description: 'Introductory CS course laying out the basics.',
     provider: {
       '@type': 'Organization',
       name: 'Course',
diff --git a/e2e/cypress/schemas/index.js b/e2e/cypress/schemas/index.js
index af95a90..1a14915 100644
--- a/e2e/cypress/schemas/index.js
+++ b/e2e/cypress/schemas/index.js
@@ -2,6 +2,7 @@ import { combineSchemas } from '@cypress/schema-tools';
 import courseVersions from './course-schema';
 import articleVersions from './article-schema';
 import blogVersions from './blog-schema';
+import productVersions from './product-schema';
 
-const schemas = combineSchemas(courseVersions, articleVersions, blogVersions);
+const schemas = combineSchemas(courseVersions, articleVersions, blogVersions, productVersions);
 export default schemas;
diff --git a/e2e/cypress/schemas/product-schema.js b/e2e/cypress/schemas/product-schema.js
new file mode 100644
index 0000000..0dba5dc
--- /dev/null
+++ b/e2e/cypress/schemas/product-schema.js
@@ -0,0 +1,126 @@
+import { versionSchemas } from '@cypress/schema-tools';
+import {
+  offers100, aggregateRating100, brand100, review100,
+} from './common';
+
+const product100 = {
+  version: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  schema: {
+    type: 'object',
+    title: 'Product',
+    description: 'An example schema describing JSON-LD for type: Product',
+    properties: {
+      '@context': {
+        type: 'string',
+        description: 'Schema.org context',
+      },
+      '@type': {
+        type: 'string',
+        description: 'JSON-LD Type',
+      },
+      name: {
+        type: 'string',
+        description: 'Product Name',
+      },
+      image: {
+        type: 'array',
+        items: {
+          type: 'string',
+        },
+        description: 'Array of image urls',
+      },
+      description: {
+        type: 'string',
+        description: 'The description of the Product',
+      },
+      npm: {
+        type: 'string',
+      },
+      gtin8: {
+        type: 'string',
+      },
+      gtin13: {
+        type: 'string',
+      },
+      gtin14: {
+        type: 'string',
+      },
+      brand: {
+        ...brand100.schema,
+        see: brand100,
+      },
+      review: {
+        type: 'array',
+        items: {
+          ...review100.schema,
+        },
+        see: review100,
+      },
+      aggregateRating: {
+        ...aggregateRating100.schema,
+        see: aggregateRating100,
+      },
+      offers: {
+        ...offers100.schema,
+        see: offers100,
+      },
+    },
+  },
+  example: {
+    '@context': 'http://schema.org/',
+    '@type': 'Product',
+    name: 'Executive Anvil',
+    image: [
+      'https://example.com/photos/1x1/photo.jpg',
+      'https://example.com/photos/4x3/photo.jpg',
+      'https://example.com/photos/16x9/photo.jpg',
+    ],
+    description:
+      "Sleeker than ACME's Classic Anvil, the Executive Anvil is perfect for the business traveler looking for something to drop from a height.",
+    mpn: '925872',
+    brand: {
+      '@type': 'Thing',
+      name: 'ACME',
+    },
+    review: [
+      {
+        '@type': 'Review',
+        author: 'Jim',
+        datePublished: '2017-01-06T03:37:40Z',
+        reviewBody:
+          'This is my favorite product yet! Thanks Nate for the example products and reviews.',
+        name: 'So awesome!!!',
+        reviewRating: {
+          '@type': 'Rating',
+          bestRating: '5',
+          ratingValue: '5',
+          worstRating: '1',
+        },
+      },
+    ],
+    aggregateRating: {
+      '@type': 'AggregateRating',
+      ratingValue: '4.4',
+      reviewCount: '89',
+    },
+    offers: {
+      '@type': 'Offer',
+      priceCurrency: 'USD',
+      price: '119.99',
+      priceValidUntil: '2020-11-05',
+      itemCondition: 'http://schema.org/UsedCondition',
+      availability: 'http://schema.org/InStock',
+      seller: {
+        '@type': 'Organization',
+        name: 'Executive Objects',
+      },
+    },
+  },
+};
+
+const productVersions = versionSchemas(product100);
+export default productVersions;
diff --git a/e2e/pages/jsonld.jsx b/e2e/pages/jsonld.jsx
index 41e5cb0..3311ad4 100644
--- a/e2e/pages/jsonld.jsx
+++ b/e2e/pages/jsonld.jsx
@@ -1,5 +1,7 @@
 import React from 'react';
-import { ArticleJsonLd, BlogJsonLd, CourseJsonLd } from '../../dist';
+import {
+  ArticleJsonLd, BlogJsonLd, CourseJsonLd, ProductJsonLd,
+} from '../../dist';
 import Links from '../components/links';
 
 export default () => (
@@ -42,6 +44,46 @@ export default () => (
       description="Course description goes right here"
     />
 
+    <ProductJsonLd
+      productName="Executive Anvil"
+      images={[
+        'https://example.com/photos/1x1/photo.jpg',
+        'https://example.com/photos/4x3/photo.jpg',
+        'https://example.com/photos/16x9/photo.jpg',
+      ]}
+      description="Sleeker than ACME's Classic Anvil, the Executive Anvil is perfect for the business traveler looking for something to drop from a height."
+      brand="ACME"
+      reviews={[
+        {
+          author: 'Jim',
+          datePublished: '2017-01-06T03:37:40Z',
+          reviewBody:
+            'This is my favorite product yet! Thanks Nate for the example products and reviews.',
+          name: 'So awesome!!!',
+          reviewRating: {
+            bestRating: '5',
+            ratingValue: '5',
+            worstRating: '1',
+          },
+        },
+      ]}
+      aggregateRating={{
+        ratingValue: '4.4',
+        reviewCount: '89',
+      }}
+      offers={{
+        price: '119.99',
+        priceCurrency: 'USD',
+        priceValidUntil: '2020-11-05',
+        itemCondition: 'http://schema.org/UsedCondition',
+        availability: 'http://schema.org/InStock',
+        seller: {
+          name: 'Executive Objects',
+        },
+      }}
+      mpn="925872"
+    />
+
     <Links />
   </>
 );
diff --git a/dist/meta/__tests__/__snapshots__/buildTags.test.js.snap b/src/meta/__tests__/__snapshots__/buildTags.spec.jsx.snap
similarity index 96%
rename from dist/meta/__tests__/__snapshots__/buildTags.test.js.snap
rename to src/meta/__tests__/__snapshots__/buildTags.spec.jsx.snap
index eb6b8d9..7d4b934 100644
--- a/dist/meta/__tests__/__snapshots__/buildTags.test.js.snap
+++ b/src/meta/__tests__/__snapshots__/buildTags.spec.jsx.snap
@@ -101,6 +101,10 @@ exports[`renders correctly 1`] = `
     content="en_IE"
     property="og:locale"
   />
+  <meta
+    content="SiteName"
+    property="og:site_name"
+  />
   <link
     href="https://www.canonical.ie"
     rel="canonical"
diff --git a/src/meta/__tests__/__snapshots__/buildTags.test.jsx.snap b/src/meta/__tests__/__snapshots__/buildTags.test.jsx.snap
deleted file mode 100644
index eb6b8d9..0000000
--- a/src/meta/__tests__/__snapshots__/buildTags.test.jsx.snap
+++ /dev/null
@@ -1,109 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders correctly 1`] = `
-<div>
-  <title>
-    This is a test title.
-  </title>
-  <meta
-    content="index,follow"
-    name="robots"
-  />
-  <meta
-    content="index,follow"
-    name="googlebot"
-  />
-  <meta
-    content="This is a test description."
-    name="description"
-  />
-  <meta
-    content="summary_large_image"
-    name="twitter:card"
-  />
-  <meta
-    content="@site"
-    name="twitter:site"
-  />
-  <meta
-    content="@handle"
-    name="twitter:creator"
-  />
-  <meta
-    content="https://www.url.ie"
-    property="og:url"
-  />
-  <meta
-    content="website"
-    property="og:type"
-  />
-  <meta
-    content="Open graph title"
-    property="og:title"
-  />
-  <meta
-    content="This is testing og:description."
-    property="og:description"
-  />
-  <meta
-    content="https://www.test.ie/image-01.jpg"
-    property="og:image"
-  />
-  <meta
-    content="Alt text right here"
-    property="og:image:alt"
-  />
-  <meta
-    content="800"
-    property="og:image:width"
-  />
-  <meta
-    content="600"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-02.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-03.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="https://www.test.ie/image-04.jpg"
-    property="og:image"
-  />
-  <meta
-    content="1200"
-    property="og:image:width"
-  />
-  <meta
-    content="1200"
-    property="og:image:height"
-  />
-  <meta
-    content="en_IE"
-    property="og:locale"
-  />
-  <link
-    href="https://www.canonical.ie"
-    rel="canonical"
-  />
-</div>
-`;
diff --git a/src/meta/__tests__/buildTags.test.jsx b/src/meta/__tests__/buildTags.spec.jsx
similarity index 96%
rename from src/meta/__tests__/buildTags.test.jsx
rename to src/meta/__tests__/buildTags.spec.jsx
index 98207db..20084f5 100644
--- a/src/meta/__tests__/buildTags.test.jsx
+++ b/src/meta/__tests__/buildTags.spec.jsx
@@ -38,7 +38,6 @@ const SEO = {
 it('renders correctly', () => {
   const tags = buildTags(SEO);
   const { container } = render(tags);
-
   expect(container).toMatchSnapshot();
 });
 
@@ -90,6 +89,8 @@ it('returns full array for default seo object', () => {
   );
   const ogLocale = container.querySelectorAll(`meta[content="${SEO.openGraph.locale}"]`);
   const ogLocaleTag = tags.filter(item => item.key === 'og:locale');
+  const ogSiteName = container.querySelectorAll(`meta[content="${SEO.openGraph.site_name}"]`);
+  const ogSiteNameTag = tags.filter(item => item.key === 'og:site_name');
   const canonicalTag = tags.filter(item => item.key === 'canonical');
 
   expect(title).toBeDefined();
@@ -124,6 +125,8 @@ it('returns full array for default seo object', () => {
   expect(Array.from(ogSetImageAlt).length).toBe(1);
   expect(Array.from(ogLocale).length).toBe(1);
   expect(Array.from(ogLocaleTag).length).toBe(1);
+  expect(Array.from(ogSiteName).length).toBe(1);
+  expect(Array.from(ogSiteNameTag).length).toBe(1);
   expect(canonicalTag[0].props.href).toBe(`${SEO.canonical}`);
   expect(Array.from(canonicalTag).length).toBe(1);
 });