diff --git a/src/components/Breadcrumbs/BreadcrumbContainer.js b/src/components/Breadcrumbs/BreadcrumbContainer.js index 8ded22a2e..589d2bbf7 100644 --- a/src/components/Breadcrumbs/BreadcrumbContainer.js +++ b/src/components/Breadcrumbs/BreadcrumbContainer.js @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import { reportAnalytics } from '../../utils/report-analytics'; import { theme } from '../../theme/docsTheme'; import { getFullBreadcrumbPath } from '../../utils/get-complete-breadcrumb-data'; +import { useSiteMetadata } from '../../hooks/use-site-metadata'; import IndividualBreadcrumb from './IndividualBreadcrumb'; import CollapsedBreadcrumbs from './CollapsedBreadcrumbs'; @@ -23,6 +24,7 @@ const initialMaxCrumbs = (breadcrumbs) => breadcrumbs.length + 1; const BreadcrumbContainer = ({ breadcrumbs }) => { const [maxCrumbs, setMaxCrumbs] = React.useState(initialMaxCrumbs(breadcrumbs)); + const { siteUrl } = useSiteMetadata(); React.useEffect(() => { const handleResize = () => { @@ -68,7 +70,7 @@ const BreadcrumbContainer = ({ breadcrumbs }) => { setIsExcessivelyTruncated={collapseBreadcrumbs} onClick={() => reportAnalytics('BreadcrumbClick', { - breadcrumbClicked: getFullBreadcrumbPath(crumb.path, true), + breadcrumbClicked: getFullBreadcrumbPath(siteUrl, crumb.path, true), }) } > diff --git a/src/components/Breadcrumbs/index.js b/src/components/Breadcrumbs/index.js index d8e064647..90ca1679a 100644 --- a/src/components/Breadcrumbs/index.js +++ b/src/components/Breadcrumbs/index.js @@ -6,6 +6,7 @@ import { theme } from '../../theme/docsTheme'; import { getCompleteBreadcrumbData } from '../../utils/get-complete-breadcrumb-data.js'; import { useBreadcrumbs } from '../../hooks/use-breadcrumbs'; import useSnootyMetadata from '../../utils/use-snooty-metadata'; +import { useSiteMetadata } from '../../hooks/use-site-metadata.js'; import BreadcrumbContainer from './BreadcrumbContainer'; const breadcrumbBodyStyle = css` @@ -35,9 +36,11 @@ const Breadcrumbs = ({ const { parentPaths } = useSnootyMetadata(); const parentPathsData = parentPathsProp ?? parentPaths[slug]; + const { siteUrl } = useSiteMetadata(); const breadcrumbs = React.useMemo( () => getCompleteBreadcrumbData({ + siteUrl, siteTitle, slug, queriedCrumbs, @@ -45,7 +48,7 @@ const Breadcrumbs = ({ selfCrumbContent: selfCrumb, pageInfo: pageInfo, }), - [parentPathsData, queriedCrumbs, siteTitle, slug, selfCrumb, pageInfo] + [siteUrl, parentPathsData, queriedCrumbs, siteTitle, slug, selfCrumb, pageInfo] ); return ( diff --git a/src/components/Footnote/FootnoteReference.js b/src/components/Footnote/FootnoteReference.js index 48419e831..46458a271 100644 --- a/src/components/Footnote/FootnoteReference.js +++ b/src/components/Footnote/FootnoteReference.js @@ -22,10 +22,6 @@ const FootnoteReference = ({ nodeData: { id, refname } }) => { const { footnotes } = useContext(FootnoteContext); const { darkMode } = useDarkMode(); - // the nodeData originates from docutils, and may be incorrect for - // anonymous footnoteReferences originating from included files -- docutils - // appears to assign IDs within the included files before they are collated - const ref = refname || id.replace('id', ''); const uid = refname ? `${refname}-${id}` : id; diff --git a/src/components/StructuredData/BreadcrumbSchema.js b/src/components/StructuredData/BreadcrumbSchema.js index c8f3f6e39..a68cc58fc 100644 --- a/src/components/StructuredData/BreadcrumbSchema.js +++ b/src/components/StructuredData/BreadcrumbSchema.js @@ -1,46 +1,28 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { getCompleteBreadcrumbData, getFullBreadcrumbPath } from '../../utils/get-complete-breadcrumb-data.js'; import { useBreadcrumbs } from '../../hooks/use-breadcrumbs'; import useSnootyMetadata from '../../utils/use-snooty-metadata'; -import { STRUCTURED_DATA_CLASSNAME } from '../../utils/structured-data.js'; - -const getBreadcrumbList = (breadcrumbs) => - breadcrumbs.map(({ path, title }, index) => { - path = getFullBreadcrumbPath(path, true); - - return { - '@type': 'ListItem', - position: index + 1, - name: title, - item: path, - }; - }); +import { BreadcrumbListSd, STRUCTURED_DATA_CLASSNAME } from '../../utils/structured-data.js'; +import { useSiteMetadata } from '../../hooks/use-site-metadata.js'; const BreadcrumbSchema = ({ slug }) => { const { parentPaths, title: siteTitle } = useSnootyMetadata(); + const { siteUrl } = useSiteMetadata(); const parentPathsSlug = parentPaths[slug]; const queriedCrumbs = useBreadcrumbs(); - const breadcrumbList = React.useMemo( - () => [ - ...getBreadcrumbList([ - ...getCompleteBreadcrumbData({ siteTitle, slug, queriedCrumbs, parentPaths: parentPathsSlug }), - ]), - ], - [siteTitle, slug, queriedCrumbs, parentPathsSlug] - ); + + const breadcrumbSd = React.useMemo(() => { + const sd = new BreadcrumbListSd({ siteUrl, siteTitle, slug, queriedCrumbs, parentPaths: parentPathsSlug }); + return sd.isValid() ? sd.toString() : undefined; + }, [siteUrl, siteTitle, slug, queriedCrumbs, parentPathsSlug]); return ( <> - {Array.isArray(queriedCrumbs.breadcrumbs) && ( + {Array.isArray(queriedCrumbs.breadcrumbs) && breadcrumbSd && ( )} diff --git a/src/hooks/use-site-metadata.js b/src/hooks/use-site-metadata.js index 698b58a06..11c37299c 100644 --- a/src/hooks/use-site-metadata.js +++ b/src/hooks/use-site-metadata.js @@ -12,6 +12,7 @@ export const useSiteMetadata = () => { parserUser patchId pathPrefix + project reposDatabase siteUrl snootyBranch diff --git a/src/utils/get-complete-breadcrumb-data.js b/src/utils/get-complete-breadcrumb-data.js index 6b25aacca..427d149ae 100644 --- a/src/utils/get-complete-breadcrumb-data.js +++ b/src/utils/get-complete-breadcrumb-data.js @@ -1,7 +1,6 @@ import { withPrefix } from 'gatsby'; -import { baseUrl } from './base-url'; +import { baseUrl, joinUrlAndPath } from './base-url'; import { assertTrailingSlash } from './assert-trailing-slash'; -import { removeLeadingSlash } from './remove-leading-slash'; import { assertLeadingSlash } from './assert-leading-slash'; import { isRelativeUrl } from './is-relative-url'; import { getUrl, getCompleteUrl } from './url-utils'; @@ -26,17 +25,18 @@ const nodesToString = (titleNodes) => { .join(''); }; -export const getFullBreadcrumbPath = (path, needsPrefix) => { - if (needsPrefix) { - path = withPrefix(path); - } +export const getFullBreadcrumbPath = (siteUrl, path, needsPrefix) => { if (isRelativeUrl(path)) { - path = baseUrl() + removeLeadingSlash(path); + if (needsPrefix) { + path = withPrefix(path); + } + path = joinUrlAndPath(siteUrl, path); } return assertTrailingSlash(path); }; export const getCompleteBreadcrumbData = ({ + siteUrl, siteTitle, slug, queriedCrumbs, @@ -50,7 +50,7 @@ export const getCompleteBreadcrumbData = ({ //get intermediate breadcrumbs const intermediateCrumbs = (queriedCrumbs?.breadcrumbs ?? []).map((crumb) => { - return { ...crumb, path: getFullBreadcrumbPath(crumb.path, false) }; + return { ...crumb, path: getFullBreadcrumbPath(siteUrl, crumb.path, false) }; }); const homeCrumb = { diff --git a/src/utils/structured-data.js b/src/utils/structured-data.js index becd2082d..91a2d2928 100644 --- a/src/utils/structured-data.js +++ b/src/utils/structured-data.js @@ -9,6 +9,7 @@ import { getFullLanguageName } from './get-language'; import { findKeyValuePair } from './find-key-value-pair'; import { getPlaintext } from './get-plaintext'; +import { getCompleteBreadcrumbData, getFullBreadcrumbPath } from './get-complete-breadcrumb-data'; // Class name to help Smartling identify all structured data, if needed export const STRUCTURED_DATA_CLASSNAME = 'structured_data'; @@ -58,6 +59,27 @@ export class StructuredData { } } +export class BreadcrumbListSd extends StructuredData { + constructor({ siteUrl, siteTitle, slug, queriedCrumbs, parentPaths }) { + super('BreadcrumbList'); + const breadcrumbs = getCompleteBreadcrumbData({ siteUrl, siteTitle, slug, queriedCrumbs, parentPaths }); + this.itemListElement = this.getBreadcrumbList(breadcrumbs, siteUrl); + } + + /** + * @param {object[]} breadcrumbs + * @param {string} siteUrl + */ + getBreadcrumbList(breadcrumbs, siteUrl) { + return breadcrumbs.map(({ path, title }, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: title, + item: getFullBreadcrumbPath(siteUrl, path, true), + })); + } +} + class HowToSd extends StructuredData { constructor({ steps, name }) { super('HowTo'); diff --git a/tests/unit/BreadcrumbContainer.test.js b/tests/unit/BreadcrumbContainer.test.js index f9a746656..c59f98ab6 100644 --- a/tests/unit/BreadcrumbContainer.test.js +++ b/tests/unit/BreadcrumbContainer.test.js @@ -1,16 +1,24 @@ import React from 'react'; +import * as Gatsby from 'gatsby'; import { render } from '@testing-library/react'; import BreadcrumbContainer from '../../src/components/Breadcrumbs/BreadcrumbContainer'; import mockData from './data/Breadcrumbs.test.json'; jest.mock(`../../src/utils/use-snooty-metadata`, () => jest.fn()); +const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); +useStaticQuery.mockImplementation(() => ({ + site: { + siteMetadata: { + siteUrl: 'https://www.mongodb.com/', + }, + }, +})); + const mountBreadcrumbContainer = (breadcrumbs) => { return render(); }; -jest.mock(`../../src/utils/use-snooty-metadata`, () => jest.fn()); - const mockIntermediateCrumbs = { title: 'MongoDB Atlas', path: 'https://www.mongodb.com/docs/atlas/', diff --git a/tests/unit/BreadcrumbSchema.test.js b/tests/unit/BreadcrumbSchema.test.js new file mode 100644 index 000000000..816ebe92a --- /dev/null +++ b/tests/unit/BreadcrumbSchema.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import * as Gatsby from 'gatsby'; +import { render } from '@testing-library/react'; +import useSnootyMetadata from '../../src/utils/use-snooty-metadata'; +import BreadcrumbSchema from '../../src/components/StructuredData/BreadcrumbSchema'; +import { mockWithPrefix } from '../utils/mock-with-prefix'; +import mockParents from './data/Breadcrumbs.test.json'; + +jest.mock(`../../src/utils/use-snooty-metadata`, () => jest.fn()); + +const mockIntermediateCrumbs = [ + { + title: 'MongoDB Atlas', + path: 'https://www.mongodb.com/docs/atlas', + }, +]; + +const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); +useStaticQuery.mockImplementation(() => ({ + site: { + siteMetadata: { + siteUrl: 'https://www.mongodb.com/', + }, + }, + allBreadcrumb: { + nodes: [ + { + project: 'realm', + breadcrumbs: mockIntermediateCrumbs, + propertyUrl: 'https://www.mongodb.com/docs/atlas/device-sdks/', + }, + ], + }, +})); + +describe('BreadcrumbSchema', () => { + beforeAll(() => { + useSnootyMetadata.mockImplementation(() => ({ + parentPaths: mockParents, + })); + mockWithPrefix('/docs/atlas/device-sdks'); + }); + + it('returns correct structured data with parents and intermediate breadcrumbs', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/tests/unit/Breadcrumbs.test.js b/tests/unit/Breadcrumbs.test.js index dc8357909..4edd97781 100644 --- a/tests/unit/Breadcrumbs.test.js +++ b/tests/unit/Breadcrumbs.test.js @@ -4,6 +4,7 @@ import { render } from '@testing-library/react'; import Breadcrumbs from '../../src/components/Breadcrumbs/index'; import useSnootyMetadata from '../../src/utils/use-snooty-metadata'; +import { mockWithPrefix } from '../utils/mock-with-prefix'; import mockData from './data/Breadcrumbs.test.json'; jest.mock(`../../src/utils/use-snooty-metadata`, () => jest.fn()); @@ -18,7 +19,7 @@ beforeAll(() => { const mockIntermediateCrumbs = [ { title: 'MongoDB Atlas', - path: '/atlas', + path: 'https://www.mongodb.com/docs/atlas/', }, ]; const useStaticQuery = jest.spyOn(Gatsby, 'useStaticQuery'); @@ -34,8 +35,15 @@ useStaticQuery.mockImplementation(() => ({ }, ], }, + site: { + siteMetadata: { + siteUrl: 'https://www.mongodb.com/', + }, + }, })); +mockWithPrefix('/docs/atlas/device-sdks'); + it('renders correctly with siteTitle', () => { const tree = render(); expect(tree.asFragment()).toMatchSnapshot(); diff --git a/tests/unit/__snapshots__/BreadcrumbSchema.test.js.snap b/tests/unit/__snapshots__/BreadcrumbSchema.test.js.snap new file mode 100644 index 000000000..92d980368 --- /dev/null +++ b/tests/unit/__snapshots__/BreadcrumbSchema.test.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BreadcrumbSchema returns correct structured data with parents and intermediate breadcrumbs 1`] = ` + + + +`; diff --git a/tests/utils/mock-with-prefix.js b/tests/utils/mock-with-prefix.js new file mode 100644 index 000000000..56fd0160f --- /dev/null +++ b/tests/utils/mock-with-prefix.js @@ -0,0 +1,24 @@ +import * as Gatsby from 'gatsby'; + +const withPrefix = jest.spyOn(Gatsby, 'withPrefix'); + +export const mockWithPrefix = (prefix) => { + withPrefix.mockImplementation((path) => { + let normalizedPrefix = prefix; + let normalizedPath = path; + + if (!normalizedPrefix.startsWith('/')) { + normalizedPrefix = `/${normalizedPrefix}`; + } + + if (normalizedPrefix.endsWith('/')) { + normalizedPrefix = normalizedPath.slice(0, -1); + } + + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + return `${normalizedPrefix}/${normalizedPath}`; + }); +};