Skip to content

Commit dbe5ae1

Browse files
committed
fix(docs): exclude unlisted category links from DocBreadcrumbs structured data
1 parent f659aef commit dbe5ae1

2 files changed

Lines changed: 194 additions & 1 deletion

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @jest-environment jsdom
8+
*/
9+
10+
// Jest doesn't allow pragma below other comments. https://github.com/facebook/jest/issues/12573
11+
// eslint-disable-next-line header/header
12+
import React from 'react';
13+
import {renderHook} from '@testing-library/react';
14+
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
15+
import {useBreadcrumbsStructuredData} from '../structuredDataUtils';
16+
import type {PropSidebarBreadcrumbsItem} from '@docusaurus/plugin-content-docs';
17+
import type {DocusaurusContext} from '@docusaurus/types';
18+
19+
const siteUrl = 'https://example.com';
20+
21+
function renderStructuredData(breadcrumbs: PropSidebarBreadcrumbsItem[]) {
22+
return renderHook(() => useBreadcrumbsStructuredData({breadcrumbs}), {
23+
wrapper: ({children}) => (
24+
<Context.Provider
25+
value={
26+
{
27+
siteConfig: {url: siteUrl},
28+
} as unknown as DocusaurusContext
29+
}>
30+
{children}
31+
</Context.Provider>
32+
),
33+
}).result.current;
34+
}
35+
36+
// Narrow itemListElement to a flat array for easier assertions
37+
type ListItem = {
38+
'@type': 'ListItem';
39+
position: number;
40+
name: string;
41+
item: string;
42+
};
43+
44+
function getItems(result: ReturnType<typeof renderStructuredData>): ListItem[] {
45+
return (result.itemListElement ?? []) as unknown as ListItem[];
46+
}
47+
48+
describe('useBreadcrumbsStructuredData', () => {
49+
it('returns a valid BreadcrumbList schema object', () => {
50+
// @context and @type are required for valid JSON-LD. A refactor that drops
51+
// either field would invalidate all structured data without any test failing.
52+
const result = renderStructuredData([
53+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
54+
]);
55+
expect(result['@context']).toBe('https://schema.org');
56+
expect(result['@type']).toBe('BreadcrumbList');
57+
});
58+
59+
it('includes breadcrumbs with href in itemListElement', () => {
60+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
61+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
62+
{
63+
type: 'category',
64+
href: '/docs/guides',
65+
label: 'Guides',
66+
items: [],
67+
collapsed: false,
68+
collapsible: true,
69+
},
70+
];
71+
const items = getItems(renderStructuredData(breadcrumbs));
72+
expect(items).toEqual([
73+
{
74+
'@type': 'ListItem',
75+
position: 1,
76+
name: 'Introduction',
77+
item: `${siteUrl}/docs/intro`,
78+
},
79+
{
80+
'@type': 'ListItem',
81+
position: 2,
82+
name: 'Guides',
83+
item: `${siteUrl}/docs/guides`,
84+
},
85+
]);
86+
});
87+
88+
it('excludes breadcrumbs without href from itemListElement', () => {
89+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
90+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
91+
{
92+
type: 'category',
93+
href: undefined,
94+
label: 'No-link Category',
95+
items: [],
96+
collapsed: false,
97+
collapsible: true,
98+
},
99+
];
100+
const items = getItems(renderStructuredData(breadcrumbs));
101+
expect(items).toHaveLength(1);
102+
expect(items[0]!.name).toBe('Introduction');
103+
});
104+
105+
it('excludes unlisted category links from itemListElement', () => {
106+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
107+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
108+
{
109+
type: 'category',
110+
href: '/docs/unlisted-category',
111+
label: 'Unlisted Category',
112+
linkUnlisted: true,
113+
items: [],
114+
collapsed: false,
115+
collapsible: true,
116+
},
117+
{type: 'link', href: '/docs/intro/page', label: 'Page'},
118+
];
119+
const items = getItems(renderStructuredData(breadcrumbs));
120+
// The unlisted category has an href but must not appear in structured data
121+
expect(items).toHaveLength(2);
122+
expect(items.map((item) => item.name)).toEqual(['Introduction', 'Page']);
123+
});
124+
125+
it('re-numbers positions contiguously after a filtered item', () => {
126+
// BreadcrumbList requires sequential position integers starting at 1.
127+
// Filtering an item from the middle must not leave a gap (e.g. 1, 3).
128+
// Also verifies that the item URLs of surviving entries are correct.
129+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
130+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
131+
{
132+
type: 'category',
133+
href: '/docs/unlisted-category',
134+
label: 'Unlisted Category',
135+
linkUnlisted: true,
136+
items: [],
137+
collapsed: false,
138+
collapsible: true,
139+
},
140+
{type: 'link', href: '/docs/intro/page', label: 'Page'},
141+
];
142+
const items = getItems(renderStructuredData(breadcrumbs));
143+
expect(items).toHaveLength(2);
144+
expect(items[0]!.position).toBe(1);
145+
expect(items[1]!.position).toBe(2); // not 3
146+
expect(items[0]!.item).toBe(`${siteUrl}/docs/intro`);
147+
expect(items[1]!.item).toBe(`${siteUrl}/docs/intro/page`);
148+
});
149+
150+
it('includes listed category links in itemListElement', () => {
151+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
152+
{
153+
type: 'category',
154+
href: '/docs/listed-category',
155+
label: 'Listed Category',
156+
linkUnlisted: false,
157+
items: [],
158+
collapsed: false,
159+
collapsible: true,
160+
},
161+
];
162+
const items = getItems(renderStructuredData(breadcrumbs));
163+
expect(items).toHaveLength(1);
164+
expect(items[0]!.item).toBe(`${siteUrl}/docs/listed-category`);
165+
});
166+
167+
it('does not exclude link-type breadcrumbs that have unlisted:true', () => {
168+
// PropSidebarItemLink has `unlisted?: boolean` — a different field from
169+
// PropSidebarItemCategory's `linkUnlisted`. An unlisted doc that appears
170+
// in a breadcrumb trail is the current page; its URL is valid and should
171+
// be emitted. The filter must not conflate the two fields.
172+
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [
173+
{type: 'link', href: '/docs/intro', label: 'Introduction'},
174+
{
175+
type: 'link',
176+
href: '/docs/unlisted-doc',
177+
label: 'Unlisted Doc',
178+
unlisted: true,
179+
},
180+
];
181+
const items = getItems(renderStructuredData(breadcrumbs));
182+
expect(items).toHaveLength(2);
183+
expect(items[1]!.name).toBe('Unlisted Doc');
184+
expect(items[1]!.item).toBe(`${siteUrl}/docs/unlisted-doc`);
185+
});
186+
});

packages/docusaurus-plugin-content-docs/src/client/structuredDataUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ export function useBreadcrumbsStructuredData({
2121
itemListElement: breadcrumbs
2222
// We filter breadcrumb items without links, they are not allowed
2323
// See also https://github.com/facebook/docusaurus/issues/9319#issuecomment-2643560845
24-
.filter((breadcrumb) => breadcrumb.href)
24+
// We also filter unlisted category links: the href is present on the
25+
// item (so the sidebar highlight still works) but must not be emitted
26+
// into structured data where it would be crawled by search engines.
27+
.filter(
28+
(breadcrumb) =>
29+
breadcrumb.href &&
30+
!(breadcrumb.type === 'category' && breadcrumb.linkUnlisted),
31+
)
2532
.map((breadcrumb, index) => ({
2633
'@type': 'ListItem',
2734
position: index + 1,

0 commit comments

Comments
 (0)