mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-03 03:12:35 +02:00
refactor(docs): theme-common shouldn't depend on docs content (#10316)
This commit is contained in:
parent
d426469608
commit
026a317fc4
51 changed files with 209 additions and 189 deletions
|
@ -39,6 +39,7 @@
|
|||
"@docusaurus/logger": "3.4.0",
|
||||
"@docusaurus/mdx-loader": "3.4.0",
|
||||
"@docusaurus/module-type-aliases": "3.4.0",
|
||||
"@docusaurus/theme-common": "3.4.0",
|
||||
"@docusaurus/types": "3.4.0",
|
||||
"@docusaurus/utils": "3.4.0",
|
||||
"@docusaurus/utils-common": "3.4.0",
|
||||
|
|
|
@ -18,7 +18,7 @@ import type {
|
|||
GlobalVersion,
|
||||
ActivePlugin,
|
||||
GlobalDoc,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
} from '../index';
|
||||
|
||||
describe('docsClientUtils', () => {
|
||||
it('getActivePlugin', () => {
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {renderHook} from '@testing-library/react-hooks';
|
||||
import {useDocsSidebar, DocsSidebarProvider} from '../docsSidebar';
|
||||
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
describe('useDocsSidebar', () => {
|
||||
it('throws if context provider is missing', () => {
|
||||
expect(
|
||||
() => renderHook(() => useDocsSidebar()).result.current?.items,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads value from context provider', () => {
|
||||
const name = 'mySidebarName';
|
||||
const items: PropSidebar = [];
|
||||
const {result} = renderHook(() => useDocsSidebar(), {
|
||||
wrapper: ({children}) => (
|
||||
<DocsSidebarProvider name={name} items={items}>
|
||||
{children}
|
||||
</DocsSidebarProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current!.name).toBe(name);
|
||||
expect(result.current!.items).toBe(items);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,764 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {renderHook} from '@testing-library/react-hooks';
|
||||
import {StaticRouter} from 'react-router-dom';
|
||||
import {Context} from '@docusaurus/core/src/client/docusaurusContext';
|
||||
import {
|
||||
findFirstSidebarItemLink,
|
||||
isActiveSidebarItem,
|
||||
useDocById,
|
||||
findSidebarCategory,
|
||||
useCurrentSidebarCategory,
|
||||
useSidebarBreadcrumbs,
|
||||
isVisibleSidebarItem,
|
||||
} from '../docsUtils';
|
||||
import {DocsSidebarProvider} from '../docsSidebar';
|
||||
import {DocsVersionProvider} from '../docsVersion';
|
||||
import type {
|
||||
PropSidebar,
|
||||
PropSidebarItem,
|
||||
PropSidebarItemCategory,
|
||||
PropSidebarItemLink,
|
||||
PropVersionMetadata,
|
||||
} from '@docusaurus/plugin-content-docs';
|
||||
import type {DocusaurusContext} from '@docusaurus/types';
|
||||
|
||||
// Make tests more readable with some useful category item defaults
|
||||
function testCategory(
|
||||
data?: Partial<PropSidebarItemCategory>,
|
||||
): PropSidebarItemCategory {
|
||||
return {
|
||||
type: 'category',
|
||||
href: undefined,
|
||||
label: 'Category label',
|
||||
items: [],
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
function testLink(data?: Partial<PropSidebarItemLink>): PropSidebarItemLink {
|
||||
return {
|
||||
type: 'link',
|
||||
href: '/testLinkHref',
|
||||
label: 'Link label',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
|
||||
return {
|
||||
version: 'versionName',
|
||||
label: 'Version Label',
|
||||
className: 'version className',
|
||||
badge: true,
|
||||
banner: 'unreleased',
|
||||
docs: {},
|
||||
docsSidebars: {},
|
||||
isLast: false,
|
||||
pluginId: 'default',
|
||||
noIndex: false,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useDocById', () => {
|
||||
const version = testVersion({
|
||||
docs: {
|
||||
doc1: {
|
||||
id: 'doc1',
|
||||
title: 'Doc 1',
|
||||
description: 'desc1',
|
||||
sidebar: 'sidebar1',
|
||||
},
|
||||
doc2: {
|
||||
id: 'doc2',
|
||||
title: 'Doc 2',
|
||||
description: 'desc2',
|
||||
sidebar: 'sidebar2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function mockUseDocById(docId: string | undefined) {
|
||||
const {result} = renderHook(() => useDocById(docId), {
|
||||
wrapper: ({children}) => (
|
||||
<DocsVersionProvider version={version}>{children}</DocsVersionProvider>
|
||||
),
|
||||
});
|
||||
return result.current;
|
||||
}
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(mockUseDocById(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds doc1', () => {
|
||||
expect(mockUseDocById('doc1')).toMatchObject({id: 'doc1'});
|
||||
});
|
||||
it('finds doc2', () => {
|
||||
expect(mockUseDocById('doc2')).toMatchObject({id: 'doc2'});
|
||||
});
|
||||
|
||||
it('throws for doc3', () => {
|
||||
expect(() => mockUseDocById('doc3')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"no version doc found by id=doc3"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSidebarCategory', () => {
|
||||
it('is able to return undefined', () => {
|
||||
expect(findSidebarCategory([], () => false)).toBeUndefined();
|
||||
expect(
|
||||
findSidebarCategory([testCategory(), testCategory()], () => false),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns first element matching predicate', () => {
|
||||
const first = testCategory();
|
||||
const second = testCategory();
|
||||
const third = testCategory();
|
||||
const sidebar = [first, second, third];
|
||||
expect(findSidebarCategory(sidebar, () => true)).toEqual(first);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === first)).toEqual(
|
||||
first,
|
||||
);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === second)).toEqual(
|
||||
second,
|
||||
);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === third)).toEqual(
|
||||
third,
|
||||
);
|
||||
});
|
||||
|
||||
it('is able to search in sub items', () => {
|
||||
const subsub1 = testCategory();
|
||||
const subsub2 = testCategory();
|
||||
const sub1 = testCategory({
|
||||
items: [subsub1, subsub2],
|
||||
});
|
||||
const sub2 = testCategory();
|
||||
const parent = testCategory({
|
||||
items: [sub1, sub2],
|
||||
});
|
||||
const sidebar = [parent];
|
||||
|
||||
expect(findSidebarCategory(sidebar, () => true)).toEqual(parent);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === sub1)).toEqual(sub1);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === sub2)).toEqual(sub2);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === subsub1)).toEqual(
|
||||
subsub1,
|
||||
);
|
||||
expect(findSidebarCategory(sidebar, (item) => item === subsub2)).toEqual(
|
||||
subsub2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFirstCategoryLink', () => {
|
||||
it('works with html item', () => {
|
||||
const htmlItem = {type: 'html', value: '<div/>'} as const;
|
||||
expect(findFirstSidebarItemLink(htmlItem)).toBeUndefined();
|
||||
expect(findFirstSidebarItemLink(htmlItem)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works with link item', () => {
|
||||
const linkItem = {
|
||||
type: 'link',
|
||||
href: '/linkHref',
|
||||
label: 'Label',
|
||||
} as const;
|
||||
|
||||
expect(findFirstSidebarItemLink(linkItem)).toBe('/linkHref');
|
||||
expect(
|
||||
findFirstSidebarItemLink({
|
||||
...linkItem,
|
||||
unlisted: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works with category without link nor child', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: undefined,
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works with category with link', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: '/itemPath',
|
||||
}),
|
||||
),
|
||||
).toBe('/itemPath');
|
||||
});
|
||||
|
||||
it('works with deeply nested category', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: '/category1',
|
||||
linkUnlisted: true,
|
||||
items: [
|
||||
{type: 'html', value: '<p>test1</p>'},
|
||||
testCategory({
|
||||
href: '/category2',
|
||||
linkUnlisted: true,
|
||||
items: [
|
||||
{type: 'html', value: '<p>test2</p>'},
|
||||
testCategory({
|
||||
href: '/category3',
|
||||
items: [
|
||||
{type: 'html', value: '<p>test2</p>'},
|
||||
testCategory({
|
||||
href: '/category4',
|
||||
linkUnlisted: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('/category3');
|
||||
});
|
||||
|
||||
it('works with deeply nested link', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: '/category1',
|
||||
linkUnlisted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/itemPathUnlisted',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
testCategory({
|
||||
href: '/category2',
|
||||
linkUnlisted: true,
|
||||
items: [
|
||||
testCategory({
|
||||
href: '/category3',
|
||||
linkUnlisted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/itemPathUnlisted2',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
testCategory({
|
||||
href: '/category4',
|
||||
linkUnlisted: true,
|
||||
}),
|
||||
{
|
||||
type: 'link',
|
||||
href: '/itemPathListed1',
|
||||
label: 'Label',
|
||||
},
|
||||
testCategory({
|
||||
href: '/category5',
|
||||
}),
|
||||
{
|
||||
type: 'link',
|
||||
href: '/itemPathListed2',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('/itemPathListed1');
|
||||
});
|
||||
|
||||
it('works with category with deeply nested category link unlisted', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: undefined,
|
||||
items: [
|
||||
{type: 'html', value: '<p>test1</p>'},
|
||||
testCategory({
|
||||
href: undefined,
|
||||
items: [
|
||||
{type: 'html', value: '<p>test2</p>'},
|
||||
testCategory({
|
||||
href: '/itemPath',
|
||||
linkUnlisted: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works with category with deeply nested link unlisted', () => {
|
||||
expect(
|
||||
findFirstSidebarItemLink(
|
||||
testCategory({
|
||||
href: undefined,
|
||||
items: [
|
||||
testCategory({
|
||||
href: undefined,
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/itemPath',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActiveSidebarItem', () => {
|
||||
it('works with link href', () => {
|
||||
const item: PropSidebarItem = {
|
||||
type: 'link',
|
||||
href: '/itemPath',
|
||||
label: 'Label',
|
||||
};
|
||||
|
||||
expect(isActiveSidebarItem(item, '/nonexistentPath')).toBe(false);
|
||||
|
||||
expect(isActiveSidebarItem(item, '/itemPath')).toBe(true);
|
||||
|
||||
// Ensure it's not trailing slash sensitive:
|
||||
expect(isActiveSidebarItem(item, '/itemPath/')).toBe(true);
|
||||
expect(
|
||||
isActiveSidebarItem({...item, href: '/itemPath/'}, '/itemPath'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('works with category href', () => {
|
||||
const item: PropSidebarItem = testCategory({
|
||||
href: '/itemPath',
|
||||
});
|
||||
|
||||
expect(isActiveSidebarItem(item, '/nonexistentPath')).toBe(false);
|
||||
|
||||
expect(isActiveSidebarItem(item, '/itemPath')).toBe(true);
|
||||
|
||||
// Ensure it's not trailing slash sensitive:
|
||||
expect(isActiveSidebarItem(item, '/itemPath/')).toBe(true);
|
||||
expect(
|
||||
isActiveSidebarItem({...item, href: '/itemPath/'}, '/itemPath'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('works with category nested items', () => {
|
||||
const item: PropSidebarItem = testCategory({
|
||||
href: '/category-path',
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-link-path',
|
||||
label: 'Label',
|
||||
},
|
||||
testCategory({
|
||||
href: '/sub-category-path',
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-sub-link-path',
|
||||
label: 'Label',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(isActiveSidebarItem(item, '/nonexistentPath')).toBe(false);
|
||||
|
||||
expect(isActiveSidebarItem(item, '/category-path')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-link-path')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-category-path')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-sub-link-path')).toBe(true);
|
||||
|
||||
// Ensure it's not trailing slash sensitive:
|
||||
expect(isActiveSidebarItem(item, '/category-path/')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-link-path/')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-category-path/')).toBe(true);
|
||||
expect(isActiveSidebarItem(item, '/sub-sub-link-path/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVisibleSidebarItem', () => {
|
||||
it('works with item', () => {
|
||||
const item: PropSidebarItem = {
|
||||
type: 'link',
|
||||
href: '/itemPath',
|
||||
label: 'Label',
|
||||
};
|
||||
|
||||
expect(isVisibleSidebarItem(item, item.href)).toBe(true);
|
||||
expect(isVisibleSidebarItem(item, '/nonexistentPath/')).toBe(true);
|
||||
|
||||
expect(isVisibleSidebarItem({...item, unlisted: false}, item.href)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isVisibleSidebarItem({...item, unlisted: undefined}, item.href),
|
||||
).toBe(true);
|
||||
|
||||
expect(isVisibleSidebarItem({...item, unlisted: true}, item.href)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isVisibleSidebarItem({...item, unlisted: true}, '/nonexistentPath/'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('works with category', () => {
|
||||
const subCategoryAllUnlisted = testCategory({
|
||||
href: '/sub-category-path',
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-sub-link-path',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-sub-link-path',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
testCategory({
|
||||
href: '/sub-sub-category-path',
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-sub-sub-link-path',
|
||||
label: 'Label',
|
||||
unlisted: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
isVisibleSidebarItem(subCategoryAllUnlisted, '/nonexistentPath'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isVisibleSidebarItem(
|
||||
subCategoryAllUnlisted,
|
||||
subCategoryAllUnlisted.href!,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isVisibleSidebarItem(subCategoryAllUnlisted, '/sub-sub-link-path'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isVisibleSidebarItem(subCategoryAllUnlisted, '/sub-sub-sub-link-path'),
|
||||
).toBe(true);
|
||||
|
||||
const categorySomeUnlisted = testCategory({
|
||||
href: '/category-path',
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
href: '/sub-link-path',
|
||||
label: 'Label',
|
||||
},
|
||||
subCategoryAllUnlisted,
|
||||
],
|
||||
});
|
||||
expect(isVisibleSidebarItem(categorySomeUnlisted, '/nonexistentPath')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isVisibleSidebarItem(categorySomeUnlisted, categorySomeUnlisted.href!),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSidebarBreadcrumbs', () => {
|
||||
const createUseSidebarBreadcrumbsMock =
|
||||
(sidebar: PropSidebar | undefined, breadcrumbsOption?: boolean) =>
|
||||
(location: string) =>
|
||||
renderHook(() => useSidebarBreadcrumbs(), {
|
||||
wrapper: ({children}) => (
|
||||
<StaticRouter location={location}>
|
||||
<Context.Provider
|
||||
value={
|
||||
{
|
||||
globalData: {
|
||||
'docusaurus-plugin-content-docs': {
|
||||
default: {path: '/', breadcrumbs: breadcrumbsOption},
|
||||
},
|
||||
},
|
||||
} as unknown as DocusaurusContext
|
||||
}>
|
||||
<DocsSidebarProvider name="sidebarName" items={sidebar}>
|
||||
{children}
|
||||
</DocsSidebarProvider>
|
||||
</Context.Provider>
|
||||
</StaticRouter>
|
||||
),
|
||||
}).result.current;
|
||||
it('returns empty for empty sidebar', () => {
|
||||
expect(createUseSidebarBreadcrumbsMock([])('/doesNotExist')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for sidebar but unknown pathname', () => {
|
||||
const sidebar = [testCategory(), testLink()];
|
||||
expect(createUseSidebarBreadcrumbsMock(sidebar)('/doesNotExist')).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('returns first level category', () => {
|
||||
const pathname = '/somePathName';
|
||||
const sidebar = [testCategory({href: pathname}), testLink()];
|
||||
|
||||
expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([
|
||||
sidebar[0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns first level link', () => {
|
||||
const pathname = '/somePathName';
|
||||
const sidebar = [testCategory(), testLink({href: pathname})];
|
||||
|
||||
expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([
|
||||
sidebar[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nested category', () => {
|
||||
const pathname = '/somePathName';
|
||||
|
||||
const categoryLevel3 = testCategory({
|
||||
href: pathname,
|
||||
});
|
||||
|
||||
const categoryLevel2 = testCategory({
|
||||
items: [
|
||||
testCategory(),
|
||||
categoryLevel3,
|
||||
testLink({href: pathname}),
|
||||
testLink(),
|
||||
],
|
||||
});
|
||||
|
||||
const categoryLevel1 = testCategory({
|
||||
items: [testLink(), categoryLevel2],
|
||||
});
|
||||
|
||||
const sidebar = [
|
||||
testLink(),
|
||||
testCategory(),
|
||||
categoryLevel1,
|
||||
testLink(),
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([
|
||||
categoryLevel1,
|
||||
categoryLevel2,
|
||||
categoryLevel3,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nested link', () => {
|
||||
const pathname = '/somePathName';
|
||||
|
||||
const link = testLink({href: pathname});
|
||||
|
||||
const categoryLevel3 = testCategory({
|
||||
items: [testLink(), link, testLink()],
|
||||
});
|
||||
|
||||
const categoryLevel2 = testCategory({
|
||||
items: [
|
||||
testCategory(),
|
||||
categoryLevel3,
|
||||
testLink({href: pathname}),
|
||||
testLink(),
|
||||
],
|
||||
});
|
||||
|
||||
const categoryLevel1 = testCategory({
|
||||
items: [testLink(), categoryLevel2],
|
||||
});
|
||||
|
||||
const sidebar = [
|
||||
testLink(),
|
||||
testCategory(),
|
||||
categoryLevel1,
|
||||
testLink(),
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([
|
||||
categoryLevel1,
|
||||
categoryLevel2,
|
||||
categoryLevel3,
|
||||
link,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when breadcrumbs disabled', () => {
|
||||
expect(createUseSidebarBreadcrumbsMock([], false)('/foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no sidebar', () => {
|
||||
expect(
|
||||
createUseSidebarBreadcrumbsMock(undefined, false)('/foo'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCurrentSidebarCategory', () => {
|
||||
const createUseCurrentSidebarCategoryMock =
|
||||
(sidebar?: PropSidebar) => (location: string) =>
|
||||
renderHook(() => useCurrentSidebarCategory(), {
|
||||
wrapper: ({children}) => (
|
||||
<DocsSidebarProvider name="sidebarName" items={sidebar}>
|
||||
<StaticRouter location={location}>{children}</StaticRouter>
|
||||
</DocsSidebarProvider>
|
||||
),
|
||||
}).result.current;
|
||||
|
||||
it('works for sidebar category', () => {
|
||||
const category: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat',
|
||||
});
|
||||
const sidebar: PropSidebar = [
|
||||
testLink(),
|
||||
testLink(),
|
||||
category,
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
const mockUseCurrentSidebarCategory =
|
||||
createUseCurrentSidebarCategoryMock(sidebar);
|
||||
|
||||
expect(mockUseCurrentSidebarCategory('/cat')).toEqual(category);
|
||||
});
|
||||
|
||||
it('works for nested sidebar category', () => {
|
||||
const category2: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat2',
|
||||
});
|
||||
const category1: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat1',
|
||||
items: [testLink(), testLink(), category2, testCategory()],
|
||||
});
|
||||
const sidebar: PropSidebar = [
|
||||
testLink(),
|
||||
testLink(),
|
||||
category1,
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
const mockUseCurrentSidebarCategory =
|
||||
createUseCurrentSidebarCategoryMock(sidebar);
|
||||
|
||||
expect(mockUseCurrentSidebarCategory('/cat2')).toEqual(category2);
|
||||
});
|
||||
|
||||
it('works for category link item', () => {
|
||||
const link = testLink({href: '/my/link/path'});
|
||||
const category: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat1',
|
||||
items: [testLink(), testLink(), link, testCategory()],
|
||||
});
|
||||
const sidebar: PropSidebar = [
|
||||
testLink(),
|
||||
testLink(),
|
||||
category,
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
const mockUseCurrentSidebarCategory =
|
||||
createUseCurrentSidebarCategoryMock(sidebar);
|
||||
|
||||
expect(mockUseCurrentSidebarCategory('/my/link/path')).toEqual(category);
|
||||
});
|
||||
|
||||
it('works for nested category link item', () => {
|
||||
const link = testLink({href: '/my/link/path'});
|
||||
const category2: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat2',
|
||||
items: [testLink(), testLink(), link, testCategory()],
|
||||
});
|
||||
const category1: PropSidebarItemCategory = testCategory({
|
||||
href: '/cat1',
|
||||
items: [testLink(), testLink(), category2, testCategory()],
|
||||
});
|
||||
const sidebar: PropSidebar = [
|
||||
testLink(),
|
||||
testLink(),
|
||||
category1,
|
||||
testCategory(),
|
||||
];
|
||||
|
||||
const mockUseCurrentSidebarCategory =
|
||||
createUseCurrentSidebarCategoryMock(sidebar);
|
||||
|
||||
expect(mockUseCurrentSidebarCategory('/my/link/path')).toEqual(category2);
|
||||
});
|
||||
|
||||
it('throws for non-category index page', () => {
|
||||
const category: PropSidebarItemCategory = {
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{type: 'link', href: '/cat/foo', label: 'Foo'},
|
||||
{type: 'link', href: '/cat/bar', label: 'Bar'},
|
||||
{type: 'link', href: '/baz', label: 'Baz'},
|
||||
],
|
||||
};
|
||||
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock([
|
||||
category,
|
||||
]);
|
||||
expect(() =>
|
||||
mockUseCurrentSidebarCategory('/cat'),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"/cat is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages."`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when sidebar is missing', () => {
|
||||
const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock();
|
||||
expect(() =>
|
||||
mockUseCurrentSidebarCategory('/cat'),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unexpected: cant find current sidebar in context"`,
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {renderHook} from '@testing-library/react-hooks';
|
||||
import {useDocsVersion, DocsVersionProvider} from '../docsVersion';
|
||||
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
|
||||
return {
|
||||
version: 'versionName',
|
||||
label: 'Version Label',
|
||||
className: 'version className',
|
||||
badge: true,
|
||||
banner: 'unreleased',
|
||||
docs: {},
|
||||
docsSidebars: {},
|
||||
isLast: false,
|
||||
pluginId: 'default',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useDocsVersion', () => {
|
||||
it('throws if context provider is missing', () => {
|
||||
expect(
|
||||
() => renderHook(() => useDocsVersion()).result.current,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Hook useDocsVersion is called outside the <DocsVersionProvider>. "`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads value from context provider', () => {
|
||||
const version = testVersion();
|
||||
const {result} = renderHook(() => useDocsVersion(), {
|
||||
wrapper: ({children}) => (
|
||||
<DocsVersionProvider version={version}>{children}</DocsVersionProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toBe(version);
|
||||
});
|
||||
});
|
71
packages/docusaurus-plugin-content-docs/src/client/doc.tsx
Normal file
71
packages/docusaurus-plugin-content-docs/src/client/doc.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useMemo, type ReactNode, useContext} from 'react';
|
||||
import {ReactContextError} from '@docusaurus/theme-common/internal';
|
||||
import type {PropDocContent} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
/**
|
||||
* The React context value returned by the `useDoc()` hook.
|
||||
* It contains useful data related to the currently browsed doc.
|
||||
*/
|
||||
export type DocContextValue = Pick<
|
||||
PropDocContent,
|
||||
'metadata' | 'frontMatter' | 'toc' | 'assets' | 'contentTitle'
|
||||
>;
|
||||
|
||||
const Context = React.createContext<DocContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Note: we don't use `PropDoc` as context value on purpose. Metadata is
|
||||
* currently stored inside the MDX component, but we may want to change that in
|
||||
* the future. This layer is a good opportunity to decouple storage from
|
||||
* consuming API, potentially allowing us to provide metadata as both props and
|
||||
* route context without duplicating the chunks in the future.
|
||||
*/
|
||||
function useContextValue(content: PropDocContent): DocContextValue {
|
||||
return useMemo(
|
||||
() => ({
|
||||
metadata: content.metadata,
|
||||
frontMatter: content.frontMatter,
|
||||
assets: content.assets,
|
||||
contentTitle: content.contentTitle,
|
||||
toc: content.toc,
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a very thin layer around the `content` received from the MDX loader.
|
||||
* It provides metadata about the doc to the children tree.
|
||||
*/
|
||||
export function DocProvider({
|
||||
children,
|
||||
content,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
content: PropDocContent;
|
||||
}): JSX.Element {
|
||||
const contextValue = useContextValue(content);
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data of the currently browsed doc. Gives access to the doc's MDX
|
||||
* Component, front matter, metadata, TOC, etc. When swizzling a low-level
|
||||
* component (e.g. the "Edit this page" link) and you need some extra metadata,
|
||||
* you don't have to drill the props all the way through the component tree:
|
||||
* simply use this hook instead.
|
||||
*/
|
||||
export function useDoc(): DocContextValue {
|
||||
const doc = useContext(Context);
|
||||
if (doc === null) {
|
||||
throw new ReactContextError('DocProvider');
|
||||
}
|
||||
return doc;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {type ReactNode, useMemo, useState, useContext} from 'react';
|
||||
import {ReactContextError} from '@docusaurus/theme-common/internal';
|
||||
|
||||
type ContextValue = {
|
||||
/**
|
||||
* The item that the user last opened, `null` when there's none open. On
|
||||
* initial render, it will always be `null`, which doesn't necessarily mean
|
||||
* there's no category open (can have 0, 1, or many being initially open).
|
||||
*/
|
||||
expandedItem: number | null;
|
||||
/**
|
||||
* Set the currently expanded item, when the user opens one. Set the value to
|
||||
* `null` when the user closes an open category.
|
||||
*/
|
||||
setExpandedItem: (a: number | null) => void;
|
||||
};
|
||||
|
||||
const EmptyContext: unique symbol = Symbol('EmptyContext');
|
||||
const Context = React.createContext<ContextValue | typeof EmptyContext>(
|
||||
EmptyContext,
|
||||
);
|
||||
|
||||
/**
|
||||
* Should be used to wrap one sidebar category level. This provider syncs the
|
||||
* expanded states of all sibling categories, and categories can choose to
|
||||
* collapse itself if another one is expanded.
|
||||
*/
|
||||
export function DocSidebarItemsExpandedStateProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
const contextValue = useMemo(
|
||||
() => ({expandedItem, setExpandedItem}),
|
||||
[expandedItem],
|
||||
);
|
||||
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function useDocSidebarItemsExpandedState(): ContextValue {
|
||||
const value = useContext(Context);
|
||||
if (value === EmptyContext) {
|
||||
throw new ReactContextError('DocSidebarItemsExpandedStateProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
useAllDocsData,
|
||||
useDocsData,
|
||||
type GlobalPluginData,
|
||||
type GlobalVersion,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
|
||||
import {useThemeConfig, type ThemeConfig} from '@docusaurus/theme-common';
|
||||
import {
|
||||
ReactContextError,
|
||||
createStorageSlot,
|
||||
} from '@docusaurus/theme-common/internal';
|
||||
|
||||
type DocsVersionPersistence = ThemeConfig['docs']['versionPersistence'];
|
||||
|
||||
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
|
||||
|
||||
const DocsPreferredVersionStorage = {
|
||||
save: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
versionName: string,
|
||||
): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
|
||||
},
|
||||
|
||||
read: (
|
||||
pluginId: string,
|
||||
persistence: DocsVersionPersistence,
|
||||
): string | null =>
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).get(),
|
||||
|
||||
clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
|
||||
createStorageSlot(storageKey(pluginId), {persistence}).del();
|
||||
},
|
||||
};
|
||||
|
||||
type DocsPreferredVersionName = string | null;
|
||||
|
||||
/** State for a single docs plugin instance */
|
||||
type DocsPreferredVersionPluginState = {
|
||||
preferredVersionName: DocsPreferredVersionName;
|
||||
};
|
||||
|
||||
/**
|
||||
* We need to store the state in storage globally, with one preferred version
|
||||
* per docs plugin instance.
|
||||
*/
|
||||
type DocsPreferredVersionState = {
|
||||
[pluginId: string]: DocsPreferredVersionPluginState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial state is always null as we can't read local storage from node SSR
|
||||
*/
|
||||
const getInitialState = (pluginIds: string[]): DocsPreferredVersionState =>
|
||||
Object.fromEntries(pluginIds.map((id) => [id, {preferredVersionName: null}]));
|
||||
|
||||
/**
|
||||
* Read storage for all docs plugins, assigning each doc plugin a preferred
|
||||
* version (if found)
|
||||
*/
|
||||
function readStorageState({
|
||||
pluginIds,
|
||||
versionPersistence,
|
||||
allDocsData,
|
||||
}: {
|
||||
pluginIds: string[];
|
||||
versionPersistence: DocsVersionPersistence;
|
||||
allDocsData: {[pluginId: string]: GlobalPluginData};
|
||||
}): DocsPreferredVersionState {
|
||||
/**
|
||||
* The storage value we read might be stale, and belong to a version that does
|
||||
* not exist in the site anymore. In such case, we remove the storage value to
|
||||
* avoid downstream errors.
|
||||
*/
|
||||
function restorePluginState(
|
||||
pluginId: string,
|
||||
): DocsPreferredVersionPluginState {
|
||||
const preferredVersionNameUnsafe = DocsPreferredVersionStorage.read(
|
||||
pluginId,
|
||||
versionPersistence,
|
||||
);
|
||||
const pluginData = allDocsData[pluginId]!;
|
||||
const versionExists = pluginData.versions.some(
|
||||
(version) => version.name === preferredVersionNameUnsafe,
|
||||
);
|
||||
if (versionExists) {
|
||||
return {preferredVersionName: preferredVersionNameUnsafe};
|
||||
}
|
||||
DocsPreferredVersionStorage.clear(pluginId, versionPersistence);
|
||||
return {preferredVersionName: null};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
pluginIds.map((id) => [id, restorePluginState(id)]),
|
||||
);
|
||||
}
|
||||
|
||||
function useVersionPersistence(): DocsVersionPersistence {
|
||||
return useThemeConfig().docs.versionPersistence;
|
||||
}
|
||||
|
||||
type ContextValue = [
|
||||
state: DocsPreferredVersionState,
|
||||
api: {
|
||||
savePreferredVersion: (pluginId: string, versionName: string) => void;
|
||||
},
|
||||
];
|
||||
|
||||
const Context = React.createContext<ContextValue | null>(null);
|
||||
|
||||
function useContextValue(): ContextValue {
|
||||
const allDocsData = useAllDocsData();
|
||||
const versionPersistence = useVersionPersistence();
|
||||
const pluginIds = useMemo(() => Object.keys(allDocsData), [allDocsData]);
|
||||
|
||||
// Initial state is empty, as we can't read browser storage in node/SSR
|
||||
const [state, setState] = useState(() => getInitialState(pluginIds));
|
||||
|
||||
// On mount, we set the state read from browser storage
|
||||
useEffect(() => {
|
||||
setState(readStorageState({allDocsData, versionPersistence, pluginIds}));
|
||||
}, [allDocsData, versionPersistence, pluginIds]);
|
||||
|
||||
// The API that we expose to consumer hooks (memo for constant object)
|
||||
const api = useMemo(() => {
|
||||
function savePreferredVersion(pluginId: string, versionName: string) {
|
||||
DocsPreferredVersionStorage.save(
|
||||
pluginId,
|
||||
versionPersistence,
|
||||
versionName,
|
||||
);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[pluginId]: {preferredVersionName: versionName},
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
savePreferredVersion,
|
||||
};
|
||||
}, [versionPersistence]);
|
||||
|
||||
return [state, api];
|
||||
}
|
||||
|
||||
function DocsPreferredVersionContextProviderUnsafe({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const value = useContextValue();
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a maybe-layer. If the docs plugin is not enabled, this provider is a
|
||||
* simple pass-through.
|
||||
*/
|
||||
export function DocsPreferredVersionContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<DocsPreferredVersionContextProviderUnsafe>
|
||||
{children}
|
||||
</DocsPreferredVersionContextProviderUnsafe>
|
||||
);
|
||||
}
|
||||
|
||||
function useDocsPreferredVersionContext(): ContextValue {
|
||||
const value = useContext(Context);
|
||||
if (!value) {
|
||||
throw new ReactContextError('DocsPreferredVersionContextProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a read-write interface to a plugin's preferred version. The
|
||||
* "preferred version" is defined as the last version that the user visited.
|
||||
* For example, if a user is using v3, even when v4 is later published, the user
|
||||
* would still be browsing v3 docs when she opens the website next time. Note,
|
||||
* the `preferredVersion` attribute will always be `null` before mount.
|
||||
*/
|
||||
export function useDocsPreferredVersion(
|
||||
pluginId: string | undefined = DEFAULT_PLUGIN_ID,
|
||||
): {
|
||||
preferredVersion: GlobalVersion | null;
|
||||
savePreferredVersionName: (versionName: string) => void;
|
||||
} {
|
||||
const docsData = useDocsData(pluginId);
|
||||
const [state, api] = useDocsPreferredVersionContext();
|
||||
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
const preferredVersion =
|
||||
docsData.versions.find(
|
||||
(version) => version.name === preferredVersionName,
|
||||
) ?? null;
|
||||
|
||||
const savePreferredVersionName = useCallback(
|
||||
(versionName: string) => {
|
||||
api.savePreferredVersion(pluginId, versionName);
|
||||
},
|
||||
[api, pluginId],
|
||||
);
|
||||
|
||||
return {preferredVersion, savePreferredVersionName};
|
||||
}
|
||||
|
||||
export function useDocsPreferredVersionByPluginId(): {
|
||||
[pluginId: string]: GlobalVersion | null;
|
||||
} {
|
||||
const allDocsData = useAllDocsData();
|
||||
const [state] = useDocsPreferredVersionContext();
|
||||
|
||||
function getPluginIdPreferredVersion(pluginId: string) {
|
||||
const docsData = allDocsData[pluginId]!;
|
||||
const {preferredVersionName} = state[pluginId]!;
|
||||
|
||||
return (
|
||||
docsData.versions.find(
|
||||
(version) => version.name === preferredVersionName,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
return Object.fromEntries(
|
||||
pluginIds.map((id) => [id, getPluginIdPreferredVersion(id)]),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {getDocsVersionSearchTag} from './docsSearch';
|
||||
|
||||
describe('getDocsVersionSearchTag', () => {
|
||||
it('works', () => {
|
||||
expect(getDocsVersionSearchTag('foo', 'bar')).toBe('docs-foo-bar');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {
|
||||
useAllDocsData,
|
||||
useActivePluginAndVersion,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
import {useDocsPreferredVersionByPluginId} from './docsPreferredVersion';
|
||||
|
||||
/** The search tag to append as each doc's metadata. */
|
||||
export function getDocsVersionSearchTag(
|
||||
pluginId: string,
|
||||
versionName: string,
|
||||
): string {
|
||||
return `docs-${pluginId}-${versionName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relevant docs tags to search.
|
||||
* This is the logic that powers the contextual search feature.
|
||||
*
|
||||
* If user is browsing Android 1.4 docs, he'll get presented with:
|
||||
* - Android '1.4' docs
|
||||
* - iOS 'preferred | latest' docs
|
||||
*
|
||||
* The result is generic and not coupled to Algolia/DocSearch on purpose.
|
||||
*/
|
||||
export function useDocsContextualSearchTags(): string[] {
|
||||
const allDocsData = useAllDocsData();
|
||||
const activePluginAndVersion = useActivePluginAndVersion();
|
||||
const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId();
|
||||
|
||||
// This can't use more specialized hooks because we are mapping over all
|
||||
// plugin instances.
|
||||
function getDocPluginTags(pluginId: string) {
|
||||
const activeVersion =
|
||||
activePluginAndVersion?.activePlugin.pluginId === pluginId
|
||||
? activePluginAndVersion.activeVersion
|
||||
: undefined;
|
||||
|
||||
const preferredVersion = docsPreferredVersionByPluginId[pluginId];
|
||||
|
||||
const latestVersion = allDocsData[pluginId]!.versions.find(
|
||||
(v) => v.isLast,
|
||||
)!;
|
||||
|
||||
const version = activeVersion ?? preferredVersion ?? latestVersion;
|
||||
|
||||
return getDocsVersionSearchTag(pluginId, version.name);
|
||||
}
|
||||
|
||||
return [...Object.keys(allDocsData).map(getDocPluginTags)];
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useMemo, useContext, type ReactNode} from 'react';
|
||||
import {ReactContextError} from '@docusaurus/theme-common/internal';
|
||||
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
// Using a Symbol because null is a valid context value (a doc with no sidebar)
|
||||
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
|
||||
const EmptyContext: unique symbol = Symbol('EmptyContext');
|
||||
|
||||
type ContextValue = {name: string; items: PropSidebar};
|
||||
|
||||
const Context = React.createContext<ContextValue | null | typeof EmptyContext>(
|
||||
EmptyContext,
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide the current sidebar to your children.
|
||||
*/
|
||||
export function DocsSidebarProvider({
|
||||
children,
|
||||
name,
|
||||
items,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name: string | undefined;
|
||||
items: PropSidebar | undefined;
|
||||
}): JSX.Element {
|
||||
const stableValue: ContextValue | null = useMemo(
|
||||
() => (name && items ? {name, items} : null),
|
||||
[name, items],
|
||||
);
|
||||
return <Context.Provider value={stableValue}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sidebar that's currently displayed, or `null` if there isn't one
|
||||
*/
|
||||
export function useDocsSidebar(): ContextValue | null {
|
||||
const value = useContext(Context);
|
||||
if (value === EmptyContext) {
|
||||
throw new ReactContextError('DocsSidebarProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
415
packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx
Normal file
415
packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx
Normal file
|
@ -0,0 +1,415 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {matchPath, useLocation} from '@docusaurus/router';
|
||||
import renderRoutes from '@docusaurus/renderRoutes';
|
||||
import {
|
||||
useActivePlugin,
|
||||
useActiveDocContext,
|
||||
useLatestVersion,
|
||||
type GlobalVersion,
|
||||
type GlobalSidebar,
|
||||
type GlobalDoc,
|
||||
} from '@docusaurus/plugin-content-docs/client';
|
||||
import {isSamePath} from '@docusaurus/theme-common/internal';
|
||||
import {uniq} from '@docusaurus/theme-common';
|
||||
import type {Props as DocRootProps} from '@theme/DocRoot';
|
||||
import {useDocsPreferredVersion} from './docsPreferredVersion';
|
||||
import {useDocsVersion} from './docsVersion';
|
||||
import {useDocsSidebar} from './docsSidebar';
|
||||
|
||||
import type {
|
||||
PropSidebar,
|
||||
PropSidebarItem,
|
||||
PropSidebarItemCategory,
|
||||
PropVersionDoc,
|
||||
PropSidebarBreadcrumbsItem,
|
||||
} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
/**
|
||||
* A null-safe way to access a doc's data by ID in the active version.
|
||||
*/
|
||||
export function useDocById(id: string): PropVersionDoc;
|
||||
/**
|
||||
* A null-safe way to access a doc's data by ID in the active version.
|
||||
*/
|
||||
export function useDocById(id: string | undefined): PropVersionDoc | undefined;
|
||||
export function useDocById(id: string | undefined): PropVersionDoc | undefined {
|
||||
const version = useDocsVersion();
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const doc = version.docs[id];
|
||||
if (!doc) {
|
||||
throw new Error(`no version doc found by id=${id}`);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function, similar to `Array#find`, but works on the sidebar tree.
|
||||
*/
|
||||
export function findSidebarCategory(
|
||||
sidebar: PropSidebar,
|
||||
predicate: (category: PropSidebarItemCategory) => boolean,
|
||||
): PropSidebarItemCategory | undefined {
|
||||
for (const item of sidebar) {
|
||||
if (item.type === 'category') {
|
||||
if (predicate(item)) {
|
||||
return item;
|
||||
}
|
||||
const subItem = findSidebarCategory(item.items, predicate);
|
||||
if (subItem) {
|
||||
return subItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort to assign a link to a sidebar category. If the category doesn't
|
||||
* have a link itself, we link to the first sub item with a link.
|
||||
*/
|
||||
export function findFirstSidebarItemCategoryLink(
|
||||
item: PropSidebarItemCategory,
|
||||
): string | undefined {
|
||||
if (item.href && !item.linkUnlisted) {
|
||||
return item.href;
|
||||
}
|
||||
|
||||
for (const subItem of item.items) {
|
||||
const link = findFirstSidebarItemLink(subItem);
|
||||
if (link) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort to assign a link to a sidebar item.
|
||||
*/
|
||||
export function findFirstSidebarItemLink(
|
||||
item: PropSidebarItem,
|
||||
): string | undefined {
|
||||
if (item.type === 'link' && !item.unlisted) {
|
||||
return item.href;
|
||||
}
|
||||
if (item.type === 'category') {
|
||||
return findFirstSidebarItemCategoryLink(item);
|
||||
}
|
||||
// Other items types, like "html"
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category associated with the current location. Should only be used
|
||||
* on category index pages.
|
||||
*/
|
||||
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
|
||||
const {pathname} = useLocation();
|
||||
const sidebar = useDocsSidebar();
|
||||
if (!sidebar) {
|
||||
throw new Error('Unexpected: cant find current sidebar in context');
|
||||
}
|
||||
const categoryBreadcrumbs = getSidebarBreadcrumbs({
|
||||
sidebarItems: sidebar.items,
|
||||
pathname,
|
||||
onlyCategories: true,
|
||||
});
|
||||
const deepestCategory = categoryBreadcrumbs.slice(-1)[0];
|
||||
if (!deepestCategory) {
|
||||
throw new Error(
|
||||
`${pathname} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`,
|
||||
);
|
||||
}
|
||||
return deepestCategory;
|
||||
}
|
||||
|
||||
const isActive = (testedPath: string | undefined, activePath: string) =>
|
||||
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
|
||||
const containsActiveSidebarItem = (
|
||||
items: PropSidebarItem[],
|
||||
activePath: string,
|
||||
) => items.some((subItem) => isActiveSidebarItem(subItem, activePath));
|
||||
|
||||
/**
|
||||
* Checks if a sidebar item should be active, based on the active path.
|
||||
*/
|
||||
export function isActiveSidebarItem(
|
||||
item: PropSidebarItem,
|
||||
activePath: string,
|
||||
): boolean {
|
||||
if (item.type === 'link') {
|
||||
return isActive(item.href, activePath);
|
||||
}
|
||||
|
||||
if (item.type === 'category') {
|
||||
return (
|
||||
isActive(item.href, activePath) ||
|
||||
containsActiveSidebarItem(item.items, activePath)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isVisibleSidebarItem(
|
||||
item: PropSidebarItem,
|
||||
activePath: string,
|
||||
): boolean {
|
||||
switch (item.type) {
|
||||
case 'category':
|
||||
return (
|
||||
isActiveSidebarItem(item, activePath) ||
|
||||
item.items.some((subItem) => isVisibleSidebarItem(subItem, activePath))
|
||||
);
|
||||
case 'link':
|
||||
// An unlisted item remains visible if it is active
|
||||
return !item.unlisted || isActiveSidebarItem(item, activePath);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVisibleSidebarItems(
|
||||
items: readonly PropSidebarItem[],
|
||||
activePath: string,
|
||||
): PropSidebarItem[] {
|
||||
return useMemo(
|
||||
() => items.filter((item) => isVisibleSidebarItem(item, activePath)),
|
||||
[items, activePath],
|
||||
);
|
||||
}
|
||||
|
||||
function getSidebarBreadcrumbs(param: {
|
||||
sidebarItems: PropSidebar;
|
||||
pathname: string;
|
||||
onlyCategories: true;
|
||||
}): PropSidebarItemCategory[];
|
||||
|
||||
function getSidebarBreadcrumbs(param: {
|
||||
sidebarItems: PropSidebar;
|
||||
pathname: string;
|
||||
}): PropSidebarBreadcrumbsItem[];
|
||||
|
||||
/**
|
||||
* Get the sidebar the breadcrumbs for a given pathname
|
||||
* Ordered from top to bottom
|
||||
*/
|
||||
function getSidebarBreadcrumbs({
|
||||
sidebarItems,
|
||||
pathname,
|
||||
onlyCategories = false,
|
||||
}: {
|
||||
sidebarItems: PropSidebar;
|
||||
pathname: string;
|
||||
onlyCategories?: boolean;
|
||||
}): PropSidebarBreadcrumbsItem[] {
|
||||
const breadcrumbs: PropSidebarBreadcrumbsItem[] = [];
|
||||
|
||||
function extract(items: PropSidebarItem[]) {
|
||||
for (const item of items) {
|
||||
if (
|
||||
(item.type === 'category' &&
|
||||
(isSamePath(item.href, pathname) || extract(item.items))) ||
|
||||
(item.type === 'link' && isSamePath(item.href, pathname))
|
||||
) {
|
||||
const filtered = onlyCategories && item.type !== 'category';
|
||||
if (!filtered) {
|
||||
breadcrumbs.unshift(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
extract(sidebarItems);
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the breadcrumbs of the current doc page, based on its sidebar location.
|
||||
* Returns `null` if there's no sidebar or breadcrumbs are disabled.
|
||||
*/
|
||||
export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
|
||||
const sidebar = useDocsSidebar();
|
||||
const {pathname} = useLocation();
|
||||
const breadcrumbsOption = useActivePlugin()?.pluginData.breadcrumbs;
|
||||
if (breadcrumbsOption === false || !sidebar) {
|
||||
return null;
|
||||
}
|
||||
return getSidebarBreadcrumbs({sidebarItems: sidebar.items, pathname});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Version candidates" are mostly useful for the layout components, which must
|
||||
* be able to work on all pages. For example, if a user has `{ type: "doc",
|
||||
* docId: "intro" }` as a navbar item, which version does that refer to? We
|
||||
* believe that it could refer to at most three version candidates:
|
||||
*
|
||||
* 1. The **active version**, the one that the user is currently browsing. See
|
||||
* {@link useActiveDocContext}.
|
||||
* 2. The **preferred version**, the one that the user last visited. See
|
||||
* {@link useDocsPreferredVersion}.
|
||||
* 3. The **latest version**, the "default". See {@link useLatestVersion}.
|
||||
*
|
||||
* @param docsPluginId The plugin ID to get versions from.
|
||||
* @returns An array of 1~3 versions with priorities defined above, guaranteed
|
||||
* to be unique and non-sparse. Will be memoized, hence stable for deps array.
|
||||
*/
|
||||
export function useDocsVersionCandidates(
|
||||
docsPluginId?: string,
|
||||
): [GlobalVersion, ...GlobalVersion[]] {
|
||||
const {activeVersion} = useActiveDocContext(docsPluginId);
|
||||
const {preferredVersion} = useDocsPreferredVersion(docsPluginId);
|
||||
const latestVersion = useLatestVersion(docsPluginId);
|
||||
return useMemo(
|
||||
() =>
|
||||
uniq(
|
||||
[activeVersion, preferredVersion, latestVersion].filter(Boolean),
|
||||
) as [GlobalVersion, ...GlobalVersion[]],
|
||||
[activeVersion, preferredVersion, latestVersion],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout components, like navbar items, must be able to work on all pages,
|
||||
* even on non-doc ones where there's no version context, so a sidebar ID could
|
||||
* be ambiguous. This hook would always return a sidebar to be linked to. See
|
||||
* also {@link useDocsVersionCandidates} for how this selection is done.
|
||||
*
|
||||
* @throws This hook throws if a sidebar with said ID is not found.
|
||||
*/
|
||||
export function useLayoutDocsSidebar(
|
||||
sidebarId: string,
|
||||
docsPluginId?: string,
|
||||
): GlobalSidebar {
|
||||
const versions = useDocsVersionCandidates(docsPluginId);
|
||||
return useMemo(() => {
|
||||
const allSidebars = versions.flatMap((version) =>
|
||||
version.sidebars ? Object.entries(version.sidebars) : [],
|
||||
);
|
||||
const sidebarEntry = allSidebars.find(
|
||||
(sidebar) => sidebar[0] === sidebarId,
|
||||
);
|
||||
if (!sidebarEntry) {
|
||||
throw new Error(
|
||||
`Can't find any sidebar with id "${sidebarId}" in version${
|
||||
versions.length > 1 ? 's' : ''
|
||||
} ${versions.map((version) => version.name).join(', ')}".
|
||||
Available sidebar ids are:
|
||||
- ${allSidebars.map((entry) => entry[0]).join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
return sidebarEntry[1];
|
||||
}, [sidebarId, versions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout components, like navbar items, must be able to work on all pages,
|
||||
* even on non-doc ones where there's no version context, so a doc ID could be
|
||||
* ambiguous. This hook would always return a doc to be linked to. See also
|
||||
* {@link useDocsVersionCandidates} for how this selection is done.
|
||||
*
|
||||
* @throws This hook throws if a doc with said ID is not found.
|
||||
*/
|
||||
export function useLayoutDoc(
|
||||
docId: string,
|
||||
docsPluginId?: string,
|
||||
): GlobalDoc | null {
|
||||
const versions = useDocsVersionCandidates(docsPluginId);
|
||||
return useMemo(() => {
|
||||
const allDocs = versions.flatMap((version) => version.docs);
|
||||
const doc = allDocs.find((versionDoc) => versionDoc.id === docId);
|
||||
if (!doc) {
|
||||
const isDraft = versions
|
||||
.flatMap((version) => version.draftIds)
|
||||
.includes(docId);
|
||||
// Drafts should be silently filtered instead of throwing
|
||||
if (isDraft) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
`Couldn't find any doc with id "${docId}" in version${
|
||||
versions.length > 1 ? 's' : ''
|
||||
} "${versions.map((version) => version.name).join(', ')}".
|
||||
Available doc ids are:
|
||||
- ${uniq(allDocs.map((versionDoc) => versionDoc.id)).join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
}, [docId, versions]);
|
||||
}
|
||||
|
||||
// TODO later read version/route directly from context
|
||||
/**
|
||||
* The docs plugin creates nested routes, with the top-level route providing the
|
||||
* version metadata, and the subroutes creating individual doc pages. This hook
|
||||
* will match the current location against all known sub-routes.
|
||||
*
|
||||
* @param props The props received by `@theme/DocRoot`
|
||||
* @returns The data of the relevant document at the current location, or `null`
|
||||
* if no document associated with the current location can be found.
|
||||
*/
|
||||
export function useDocRootMetadata({route}: DocRootProps): null | {
|
||||
/** The element that should be rendered at the current location. */
|
||||
docElement: JSX.Element;
|
||||
/**
|
||||
* The name of the sidebar associated with the current doc. `sidebarName` and
|
||||
* `sidebarItems` correspond to the value of {@link useDocsSidebar}.
|
||||
*/
|
||||
sidebarName: string | undefined;
|
||||
/** The items of the sidebar associated with the current doc. */
|
||||
sidebarItems: PropSidebar | undefined;
|
||||
} {
|
||||
const location = useLocation();
|
||||
const versionMetadata = useDocsVersion();
|
||||
const docRoutes = route.routes!;
|
||||
const currentDocRoute = docRoutes.find((docRoute) =>
|
||||
matchPath(location.pathname, docRoute),
|
||||
);
|
||||
if (!currentDocRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For now, the sidebarName is added as route config: not ideal!
|
||||
const sidebarName = currentDocRoute.sidebar as string;
|
||||
|
||||
const sidebarItems = sidebarName
|
||||
? versionMetadata.docsSidebars[sidebarName]
|
||||
: undefined;
|
||||
|
||||
const docElement = renderRoutes(docRoutes);
|
||||
|
||||
return {
|
||||
docElement,
|
||||
sidebarName,
|
||||
sidebarItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items we don't want to display on the doc card list view
|
||||
* @param items
|
||||
*/
|
||||
export function filterDocCardListItems(
|
||||
items: PropSidebarItem[],
|
||||
): PropSidebarItem[] {
|
||||
return items.filter((item) => {
|
||||
const canHaveLink = item.type === 'category' || item.type === 'link';
|
||||
if (canHaveLink) {
|
||||
return !!findFirstSidebarItemLink(item);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {type ReactNode, useContext} from 'react';
|
||||
import {ReactContextError} from '@docusaurus/theme-common/internal';
|
||||
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const Context = React.createContext<PropVersionMetadata | null>(null);
|
||||
|
||||
/**
|
||||
* Provide the current version's metadata to your children.
|
||||
*/
|
||||
export function DocsVersionProvider({
|
||||
children,
|
||||
version,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
version: PropVersionMetadata | null;
|
||||
}): JSX.Element {
|
||||
return <Context.Provider value={version}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version metadata of the current doc page.
|
||||
*/
|
||||
export function useDocsVersion(): PropVersionMetadata {
|
||||
const version = useContext(Context);
|
||||
if (version === null) {
|
||||
throw new ReactContextError('DocsVersionProvider');
|
||||
}
|
||||
return version;
|
||||
}
|
|
@ -20,6 +20,45 @@ import {
|
|||
} from './docsClientUtils';
|
||||
import type {UseDataOptions} from '@docusaurus/types';
|
||||
|
||||
export {
|
||||
useDocById,
|
||||
findSidebarCategory,
|
||||
findFirstSidebarItemLink,
|
||||
isActiveSidebarItem,
|
||||
isVisibleSidebarItem,
|
||||
useVisibleSidebarItems,
|
||||
useSidebarBreadcrumbs,
|
||||
useDocsVersionCandidates,
|
||||
useLayoutDoc,
|
||||
useLayoutDocsSidebar,
|
||||
useDocRootMetadata,
|
||||
useCurrentSidebarCategory,
|
||||
filterDocCardListItems,
|
||||
} from './docsUtils';
|
||||
|
||||
export {useDocsPreferredVersion} from './docsPreferredVersion';
|
||||
|
||||
export {
|
||||
DocSidebarItemsExpandedStateProvider,
|
||||
useDocSidebarItemsExpandedState,
|
||||
} from './docSidebarItemsExpandedState';
|
||||
|
||||
export {DocsVersionProvider, useDocsVersion} from './docsVersion';
|
||||
|
||||
export {DocsSidebarProvider, useDocsSidebar} from './docsSidebar';
|
||||
|
||||
export {DocProvider, useDoc, type DocContextValue} from './doc';
|
||||
|
||||
export {
|
||||
useDocsPreferredVersionByPluginId,
|
||||
DocsPreferredVersionContextProvider,
|
||||
} from './docsPreferredVersion';
|
||||
|
||||
export {
|
||||
useDocsContextualSearchTags,
|
||||
getDocsVersionSearchTag,
|
||||
} from './docsSearch';
|
||||
|
||||
export type ActivePlugin = {
|
||||
pluginId: string;
|
||||
pluginData: GlobalPluginData;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue