feat(theme): make it possible to provide your own page title formatter (#11090)

This commit is contained in:
Sébastien Lorber 2025-04-11 19:16:17 +02:00 committed by GitHub
parent 5b944d6b64
commit 730ce485ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 386 additions and 107 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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