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) + }) + ]) + ); + }); +});