refactor(docs): theme-common shouldn't depend on docs content (#10316)

This commit is contained in:
Sébastien Lorber 2024-07-23 10:50:07 +02:00 committed by GitHub
parent d426469608
commit 026a317fc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 209 additions and 189 deletions

View file

@ -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",

View file

@ -18,7 +18,7 @@ import type {
GlobalVersion,
ActivePlugin,
GlobalDoc,
} from '@docusaurus/plugin-content-docs/client';
} from '../index';
describe('docsClientUtils', () => {
it('getActivePlugin', () => {

View file

@ -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);
});
});

View file

@ -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"`,
);
});
});

View file

@ -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);
});
});

View 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;
}

View file

@ -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;
}

View file

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

View file

@ -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');
});
});

View file

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

View file

@ -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;
}

View 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;
});
}

View file

@ -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;
}

View file

@ -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;