mirror of
https://github.com/facebook/docusaurus.git
synced 2025-08-04 01:09:20 +02:00
feat(theme): make it possible to provide your own page title formatter (#11090)
This commit is contained in:
parent
5b944d6b64
commit
730ce485ba
17 changed files with 386 additions and 107 deletions
|
@ -43,7 +43,10 @@ export {
|
|||
|
||||
export {DEFAULT_SEARCH_TAG} from './utils/searchUtils';
|
||||
|
||||
export {useTitleFormatter} from './utils/generalUtils';
|
||||
export {
|
||||
TitleFormatterProvider,
|
||||
useTitleFormatter,
|
||||
} from './utils/titleFormatterUtils';
|
||||
|
||||
export {useLocationChange} from './utils/useLocationChange';
|
||||
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* 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 {Context} from '@docusaurus/core/src/client/docusaurusContext';
|
||||
import {useTitleFormatter} from '../generalUtils';
|
||||
import type {DocusaurusContext} from '@docusaurus/types';
|
||||
|
||||
describe('useTitleFormatter', () => {
|
||||
const createUseTitleFormatterMock =
|
||||
(context: DocusaurusContext) => (title?: string) =>
|
||||
renderHook(() => useTitleFormatter(title), {
|
||||
wrapper: ({children}) => (
|
||||
<Context.Provider value={context}>{children}</Context.Provider>
|
||||
),
|
||||
}).result.current;
|
||||
it('works', () => {
|
||||
const mockUseTitleFormatter = createUseTitleFormatterMock({
|
||||
siteConfig: {
|
||||
title: 'my site',
|
||||
titleDelimiter: '·',
|
||||
},
|
||||
} as DocusaurusContext);
|
||||
expect(mockUseTitleFormatter('a page')).toBe('a page · my site');
|
||||
expect(mockUseTitleFormatter(undefined)).toBe('my site');
|
||||
expect(mockUseTitleFormatter(' ')).toBe('my site');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 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 {TitleFormatterFnDefault} from '../titleFormatterUtils';
|
||||
|
||||
describe('TitleFormatterFnDefault', () => {
|
||||
it('works', () => {
|
||||
expect(
|
||||
TitleFormatterFnDefault({
|
||||
title: 'a page',
|
||||
siteTitle: 'my site',
|
||||
titleDelimiter: '·',
|
||||
}),
|
||||
).toBe('a page · my site');
|
||||
});
|
||||
|
||||
it('ignores empty title', () => {
|
||||
expect(
|
||||
TitleFormatterFnDefault({
|
||||
title: ' ',
|
||||
siteTitle: 'my site',
|
||||
titleDelimiter: '·',
|
||||
}),
|
||||
).toBe('my site');
|
||||
});
|
||||
|
||||
it('does not duplicate site title', () => {
|
||||
// Users may pass <Layout title={siteTitle}> leading to duplicate titles
|
||||
// By default it's preferable to avoid duplicate siteTitle
|
||||
// See also https://github.com/facebook/docusaurus/issues/5878#issuecomment-961505856
|
||||
expect(
|
||||
TitleFormatterFnDefault({
|
||||
title: 'my site',
|
||||
siteTitle: 'my site',
|
||||
titleDelimiter: '·',
|
||||
}),
|
||||
).toBe('my site');
|
||||
});
|
||||
});
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* 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 useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
|
||||
/**
|
||||
* Formats the page's title based on relevant site config and other contexts.
|
||||
*/
|
||||
export function useTitleFormatter(title?: string | undefined): string {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
const {title: siteTitle, titleDelimiter} = siteConfig;
|
||||
return title?.trim().length
|
||||
? `${title.trim()} ${titleDelimiter} ${siteTitle}`
|
||||
: siteTitle;
|
||||
}
|
|
@ -10,7 +10,7 @@ import clsx from 'clsx';
|
|||
import Head from '@docusaurus/Head';
|
||||
import useRouteContext from '@docusaurus/useRouteContext';
|
||||
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
|
||||
import {useTitleFormatter} from './generalUtils';
|
||||
import {useTitleFormatter} from './titleFormatterUtils';
|
||||
|
||||
type PageMetadataProps = {
|
||||
readonly title?: string;
|
||||
|
@ -20,6 +20,55 @@ type PageMetadataProps = {
|
|||
readonly children?: ReactNode;
|
||||
};
|
||||
|
||||
function TitleMetadata({title}: {title: string}) {
|
||||
const titleFormatter = useTitleFormatter();
|
||||
const formattedTitle = titleFormatter.format(title);
|
||||
return (
|
||||
<Head>
|
||||
<title>{formattedTitle}</title>
|
||||
<meta property="og:title" content={formattedTitle} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionMetadata({description}: {description: string}) {
|
||||
return (
|
||||
<Head>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageMetadata({image}: {image: string}) {
|
||||
const {withBaseUrl} = useBaseUrlUtils();
|
||||
const pageImage = withBaseUrl(image, {absolute: true});
|
||||
return (
|
||||
<Head>
|
||||
<meta property="og:image" content={pageImage} />
|
||||
<meta name="twitter:image" content={pageImage} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordsMetadata({
|
||||
keywords,
|
||||
}: {
|
||||
keywords: PageMetadataProps['keywords'];
|
||||
}) {
|
||||
return (
|
||||
<Head>
|
||||
<meta
|
||||
name="keywords"
|
||||
content={
|
||||
// https://github.com/microsoft/TypeScript/issues/17002
|
||||
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component to manipulate page metadata and override site defaults.
|
||||
* Works in the same way as Helmet.
|
||||
|
@ -31,33 +80,14 @@ export function PageMetadata({
|
|||
image,
|
||||
children,
|
||||
}: PageMetadataProps): ReactNode {
|
||||
const pageTitle = useTitleFormatter(title);
|
||||
const {withBaseUrl} = useBaseUrlUtils();
|
||||
const pageImage = image ? withBaseUrl(image, {absolute: true}) : undefined;
|
||||
|
||||
return (
|
||||
<Head>
|
||||
{title && <title>{pageTitle}</title>}
|
||||
{title && <meta property="og:title" content={pageTitle} />}
|
||||
|
||||
{description && <meta name="description" content={description} />}
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
|
||||
{keywords && (
|
||||
<meta
|
||||
name="keywords"
|
||||
content={
|
||||
// https://github.com/microsoft/TypeScript/issues/17002
|
||||
(Array.isArray(keywords) ? keywords.join(',') : keywords) as string
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageImage && <meta property="og:image" content={pageImage} />}
|
||||
{pageImage && <meta name="twitter:image" content={pageImage} />}
|
||||
|
||||
{children}
|
||||
</Head>
|
||||
<>
|
||||
{title && <TitleMetadata title={title} />}
|
||||
{description && <DescriptionMetadata description={description} />}
|
||||
{keywords && <KeywordsMetadata keywords={keywords} />}
|
||||
{image && <ImageMetadata image={image} />}
|
||||
{children && <Head>{children}</Head>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* 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 {createContext, useContext} from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import useRouteContext from '@docusaurus/useRouteContext';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
|
||||
type TitleFormatterParams = {
|
||||
/**
|
||||
* The page title to format
|
||||
* Usually provided with these APIs:
|
||||
* - <PageMetadata title={title}>
|
||||
* - useTitleFormatter().format(title)
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The siteConfig.title value
|
||||
*/
|
||||
siteTitle: string;
|
||||
|
||||
/**
|
||||
* The siteConfig.titleDelimiter value
|
||||
*/
|
||||
titleDelimiter: string;
|
||||
|
||||
/**
|
||||
* The plugin that created the page you are on
|
||||
*/
|
||||
plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the full formatting function, including all useful params
|
||||
* Can be customized through React context with the provider
|
||||
*/
|
||||
export type TitleFormatterFn = (params: TitleFormatterParams) => string;
|
||||
|
||||
/**
|
||||
* The default formatter is provided in params for convenience
|
||||
*/
|
||||
export type TitleFormatterFnWithDefault = (
|
||||
params: TitleFormatterParams & {
|
||||
defaultFormatter: (params: TitleFormatterParams) => string;
|
||||
},
|
||||
) => string;
|
||||
|
||||
export const TitleFormatterFnDefault: TitleFormatterFn = ({
|
||||
title,
|
||||
siteTitle,
|
||||
titleDelimiter,
|
||||
}): string => {
|
||||
const trimmedTitle = title?.trim();
|
||||
if (!trimmedTitle || trimmedTitle === siteTitle) {
|
||||
return siteTitle;
|
||||
}
|
||||
return `${trimmedTitle} ${titleDelimiter} ${siteTitle}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the simpler API exposed to theme/users
|
||||
*/
|
||||
type TitleFormatter = {format: (title: string) => string};
|
||||
|
||||
const TitleFormatterContext = createContext<TitleFormatterFnWithDefault | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function TitleFormatterProvider({
|
||||
formatter,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
formatter: TitleFormatterFnWithDefault;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TitleFormatterContext.Provider value={formatter}>
|
||||
{children}
|
||||
</TitleFormatterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTitleFormatterContext() {
|
||||
const value = useContext(TitleFormatterContext);
|
||||
if (value === null) {
|
||||
throw new ReactContextError('TitleFormatterProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function to format the page title
|
||||
*/
|
||||
export function useTitleFormatter(): TitleFormatter {
|
||||
const formatter = useTitleFormatterContext();
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
const {title: siteTitle, titleDelimiter} = siteConfig;
|
||||
|
||||
// Unfortunately we can only call this hook here, not in the provider
|
||||
// Route context can't be accessed in any provider applied above the router
|
||||
const {plugin} = useRouteContext();
|
||||
|
||||
return {
|
||||
format: (title: string) =>
|
||||
formatter({
|
||||
title,
|
||||
siteTitle,
|
||||
titleDelimiter,
|
||||
plugin,
|
||||
defaultFormatter: TitleFormatterFnDefault,
|
||||
}),
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue