From 42bc8d043dc2fc818d9d3fe1ec7f8263d85b71b5 Mon Sep 17 00:00:00 2001 From: Arctic Ice Studio Date: Sat, 22 Dec 2018 21:33:16 +0100 Subject: [PATCH] Implement `SiteMetadata` component for SEO & social media representation Implemented the core atom `SiteMetadata` that injects global metadata like documented in the "SEO & Social Media Representation" design concept (1). Next to general data like the page title and canonical URL it includes data for the Open Graph Protocol (2) and Twitter Cards (3). The component doesn't render any UI, but injects the elements into the `` using React Helmet (4). For more details read the great documentation about SEO with Gatsby (5). References: (1) https://github.com/arcticicestudio/nord-docs/issues/100 (2) http://ogp.me (3) https://developer.twitter.com/en/docs/tweets/optimize-with-cards/guides/getting-started.html (4) https://github.com/nfl/react-helmet (5) https://www.gatsbyjs.org/docs/seo Associated epic: GH-100 GH-101 --- .../atoms/core/SiteMetadata/JsonLd.jsx | 78 +++++++++ .../atoms/core/SiteMetadata/SiteMetadata.jsx | 162 ++++++++++++++++++ .../atoms/core/SiteMetadata/index.js | 14 ++ .../layouts/core/BaseLayout/BaseLayout.jsx | 7 +- test/__mocks__/gatsby.js | 36 ++++ test/__utils__/renderWithTheme.jsx | 2 +- .../core/SiteMetadata/SiteMetadata.test.jsx | 157 +++++++++++++++++ 7 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/components/atoms/core/SiteMetadata/JsonLd.jsx create mode 100644 src/components/atoms/core/SiteMetadata/SiteMetadata.jsx create mode 100644 src/components/atoms/core/SiteMetadata/index.js create mode 100644 test/__mocks__/gatsby.js create mode 100644 test/components/atoms/core/SiteMetadata/SiteMetadata.test.jsx diff --git a/src/components/atoms/core/SiteMetadata/JsonLd.jsx b/src/components/atoms/core/SiteMetadata/JsonLd.jsx new file mode 100644 index 00000000..b622a904 --- /dev/null +++ b/src/components/atoms/core/SiteMetadata/JsonLd.jsx @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import React from "react"; +import PropTypes from "prop-types"; +import Helmet from "react-helmet"; + +/** + * Provides the linked data schema using the JSON-LD specification. + * + * @author Arctic Ice Studio + * @author Sven Greb + * @since 0.4.0 + * @see https://json-ld.org + * @see https://schema.org + */ +const JsonLd = ({ author, canonicalUrl, defaultTitle, description, imageUrl, keywords, title, url }) => { + const baseSchema = [ + { + "@context": "http://schema.org", + "@type": "Website", + url, + canonicalUrl, + name: title, + alternateName: defaultTitle, + description, + author: { + "@type": "Person", + name: author.name, + url: author.url + }, + publisher: { + "@type": "Organization", + name: author.name, + url: author.url, + logo: imageUrl + }, + creator: { + "@type": "Person", + name: author.name, + url: author.url + }, + image: { + "@type": "ImageObject", + url: imageUrl + }, + keywords + } + ]; + + return ( + + + + ); +}; + +JsonLd.propTypes = { + author: PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string + }).isRequired, + canonicalUrl: PropTypes.string.isRequired, + defaultTitle: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + imageUrl: PropTypes.string.isRequired, + keywords: PropTypes.arrayOf(PropTypes.string).isRequired, + title: PropTypes.string.isRequired, + url: PropTypes.string.isRequired +}; + +export default React.memo(JsonLd); diff --git a/src/components/atoms/core/SiteMetadata/SiteMetadata.jsx b/src/components/atoms/core/SiteMetadata/SiteMetadata.jsx new file mode 100644 index 00000000..8701a089 --- /dev/null +++ b/src/components/atoms/core/SiteMetadata/SiteMetadata.jsx @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import React, { Fragment } from "react"; +import PropTypes from "prop-types"; +import { Helmet } from "react-helmet"; +import { graphql, StaticQuery } from "gatsby"; + +import metadataBanner from "assets/images/metadata-banner.png"; + +import JsonLd from "./JsonLd"; + +const PureSiteMetadata = ({ + data: { + site: { + siteMetadata: { + keywords: keywordsNordDocs, + nord: { + author, + description, + keywords: keywordsNord, + links: { + social: { twitter } + }, + title + }, + siteUrl + } + } + }, + pathName +}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +/** + * Provides metadata tags that'll be injected into the `` for SEO & social media purposes including + * "Twitter Card", "Open Graph Protocol" and "JSON-LD" specification elements. + * + * @author Arctic Ice Studio + * @author Sven Greb + * @since 0.4.0 + * @see https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/summary-card-with-large-image + * @see https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/markup + * @see https://developers.facebook.com/docs/sharing/opengraph/object-properties + * @see http://ogp.me + * @see https://developers.facebook.com/docs/sharing/best-practices + * @see https://json-ld.org + * @see https://schema.org + * @see https://rdfa.info + * @see https://en.wikipedia.org/wiki/RDFa + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta + */ +const SiteMetadata = ({ pathName, ...passProp }) => ( + } + /> +); + +PureSiteMetadata.propTypes = { + data: PropTypes.shape({ + site: PropTypes.shape({ + siteMetadata: PropTypes.shape({ + keywords: PropTypes.arrayOf(PropTypes.string), + nord: PropTypes.shape({ + author: PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string + }), + description: PropTypes.string, + keywords: PropTypes.arrayOf(PropTypes.string), + links: PropTypes.shape({ + social: PropTypes.shape({ + twitter: PropTypes.shape({ + id: PropTypes.string + }) + }) + }), + title: PropTypes.string + }), + siteUrl: PropTypes.string + }) + }) + }).isRequired, + pathName: PropTypes.string.isRequired +}; + +SiteMetadata.propTypes = { pathName: PropTypes.string.isRequired }; + +export { PureSiteMetadata }; +export default SiteMetadata; diff --git a/src/components/atoms/core/SiteMetadata/index.js b/src/components/atoms/core/SiteMetadata/index.js new file mode 100644 index 00000000..84c660cb --- /dev/null +++ b/src/components/atoms/core/SiteMetadata/index.js @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import JsonLd from "./JsonLd"; +import SiteMetadata, { PureSiteMetadata } from "./SiteMetadata"; + +export { JsonLd, PureSiteMetadata }; +export default SiteMetadata; diff --git a/src/components/layouts/core/BaseLayout/BaseLayout.jsx b/src/components/layouts/core/BaseLayout/BaseLayout.jsx index 3f0eb872..7e339530 100644 --- a/src/components/layouts/core/BaseLayout/BaseLayout.jsx +++ b/src/components/layouts/core/BaseLayout/BaseLayout.jsx @@ -13,6 +13,7 @@ import PropTypes from "prop-types"; import Header from "organisms/core/Header"; import Page from "containers/core/Page"; import Root from "containers/core/Root"; +import SiteMetadata from "atoms/core/SiteMetadata"; /** * The base page layout providing the main container that wraps the content. @@ -21,9 +22,10 @@ import Root from "containers/core/Root"; * @author Sven Greb * @since 0.3.0 */ -const BaseLayout = ({ children }) => ( +const BaseLayout = ({ children, pathName }) => ( +
{children} @@ -31,7 +33,8 @@ const BaseLayout = ({ children }) => ( ); BaseLayout.propTypes = { - children: PropTypes.node.isRequired + children: PropTypes.node.isRequired, + pathName: PropTypes.string.isRequired }; export default BaseLayout; diff --git a/test/__mocks__/gatsby.js b/test/__mocks__/gatsby.js new file mode 100644 index 00000000..31e7febd --- /dev/null +++ b/test/__mocks__/gatsby.js @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +/** + * @file A mock for the `gatsby` module which makes it a lot easier to test components that use the `Link` component or + * any GraphQL feature. + * @author Arctic Ice Studio + * @author Sven Greb + * @see https://www.gatsbyjs.org/docs/unit-testing/#mocking-gatsby + * @see https://jestjs.io/docs/en/manual-mocks + * @since 0.4.0 + */ + +const React = require("react"); + +const gatsby = jest.requireActual("gatsby"); + +module.exports = { + ...gatsby, + graphql: jest.fn(), + Link: jest.fn().mockImplementation( + ({ to, ...rest }) => + React.createElement("a", { + ...rest, + href: to + }) + /* eslint-disable-next-line function-paren-newline */ + ), + StaticQuery: jest.fn() +}; diff --git a/test/__utils__/renderWithTheme.jsx b/test/__utils__/renderWithTheme.jsx index db3128fb..102f56a1 100644 --- a/test/__utils__/renderWithTheme.jsx +++ b/test/__utils__/renderWithTheme.jsx @@ -20,6 +20,6 @@ import Root from "containers/core/Root"; * @author Sven Greb * @since 0.3.0 */ -const renderWithTheme = components => render({components}); +const renderWithTheme = (components, options = {}) => render({components}, options); export default renderWithTheme; diff --git a/test/components/atoms/core/SiteMetadata/SiteMetadata.test.jsx b/test/components/atoms/core/SiteMetadata/SiteMetadata.test.jsx new file mode 100644 index 00000000..4d57a662 --- /dev/null +++ b/test/components/atoms/core/SiteMetadata/SiteMetadata.test.jsx @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import React from "react"; +import { Helmet } from "react-helmet"; + +import { renderWithTheme } from "nord-docs-test-utils"; +import { PureSiteMetadata as SiteMetadata } from "atoms/core/SiteMetadata"; +import { metadataNord, metadataNordDocs } from "data/project"; + +const staticQueryResultDataMock = { + site: { + siteMetadata: { + keywords: metadataNordDocs.keywords, + nord: { + author: { + name: metadataNord.author.name, + url: metadataNord.author.url + }, + description: metadataNord.description, + keywords: metadataNord.keywords, + links: { + social: { + twitter: metadataNord.links.social.twitter + } + }, + title: metadataNord.title + }, + siteUrl: metadataNord.homepage + } + } +}; + +describe("data consistency", () => { + test("contains required Open Graph Protocol meta tags", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.metaTags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "og:title", + content: expect.stringContaining(metadataNord.title) + }), + expect.objectContaining({ + property: "og:type", + content: expect.any(String) + }), + expect.objectContaining({ + property: "og:image", + content: expect.stringContaining(metadataNord.homepage) || expect.any(String) + }), + expect.objectContaining({ + property: "og:url", + content: expect.stringContaining(metadataNord.homepage) + }) + ]) + ); + }); + + test("contains additional Open Graph Protocol meta tags", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.metaTags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "og:locale", + content: expect.any(String) + }), + expect.objectContaining({ + property: "og:site_name", + content: expect.stringContaining(metadataNord.title) + }), + expect.objectContaining({ + property: "og:image:type", + content: expect.stringContaining("image/") + }) + ]) + ); + }); + + test("contains required Twitter Card meta tags", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.metaTags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "twitter:card", + content: expect.stringContaining("summary_large_image") || expect.stringContaining("summary") + }), + expect.objectContaining({ + name: "twitter:site", + content: expect.stringContaining(metadataNord.links.social.twitter.id) + }), + expect.objectContaining({ + name: "twitter:description", + content: expect.any(String) + }), + expect.objectContaining({ + name: "twitter:image", + content: expect.stringContaining(metadataNord.homepage) + }) + ]) + ); + }); + + test("contains additional Twitter Card meta tags", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.metaTags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "twitter:image:alt", + content: expect.any(String) + }), + expect.objectContaining({ + name: "twitter:creator", + content: expect.any(String) + }) + ]) + ); + }); + + test("contains Open Graph Protocol HTML schema `prefix` attribute", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.htmlAttributes).toEqual( + expect.objectContaining({ + prefix: expect.stringContaining("ogp.me") + }) + ); + }); + + test("contains JSON-LD schema linked data `script` tag", () => { + renderWithTheme(); + const generatedHelmetData = Helmet.peek(); + + expect(generatedHelmetData.scriptTags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: expect.stringContaining("ld+json"), + innerHTML: expect.any(String) + }) + ]) + ); + }); +});