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

@ -122,6 +122,15 @@ declare module '@theme/Root' {
export default function Root({children}: Props): ReactNode; export default function Root({children}: Props): ReactNode;
} }
declare module '@theme/ThemeProvider' {
import type {ReactNode} from 'react';
export interface Props {
readonly children: ReactNode;
}
export default function ThemeProvider({children}: Props): ReactNode;
}
declare module '@theme/SiteMetadata' { declare module '@theme/SiteMetadata' {
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';

View file

@ -71,7 +71,7 @@
<div class="blog-posts"> <div class="blog-posts">
<xsl:for-each select="atom:feed/atom:entry"> <xsl:for-each select="atom:feed/atom:entry">
<div class="blog-post"> <div class="blog-post">
<h3><a href="{atom:link[@rel='alternate']/@href}"><xsl:value-of <h3><a href="{atom:link/@href}"><xsl:value-of
select="atom:title" select="atom:title"
/></a></h3> /></a></h3>
<div class="blog-post-date"> <div class="blog-post-date">

View file

@ -1569,6 +1569,17 @@ declare module '@theme/ThemedImage' {
export default function ThemedImage(props: Props): ReactNode; export default function ThemedImage(props: Props): ReactNode;
} }
declare module '@theme/ThemeProvider/TitleFormatter' {
import type {ReactNode} from 'react';
export interface Props {
readonly children: ReactNode;
}
export default function ThemeProviderTitleFormatter({
children,
}: Props): ReactNode;
}
declare module '@theme/Details' { declare module '@theme/Details' {
import {Details, type DetailsProps} from '@docusaurus/theme-common/Details'; import {Details, type DetailsProps} from '@docusaurus/theme-common/Details';

View file

@ -0,0 +1,27 @@
/**
* 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 ComponentProps, type ReactNode} from 'react';
import {TitleFormatterProvider} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/ThemeProvider/TitleFormatter';
type FormatterProp = ComponentProps<typeof TitleFormatterProvider>['formatter'];
const formatter: FormatterProp = (params) => {
// Add your own title formatting logic here!
return params.defaultFormatter(params);
};
export default function ThemeProviderTitleFormatter({
children,
}: Props): ReactNode {
return (
<TitleFormatterProvider formatter={formatter}>
{children}
</TitleFormatterProvider>
);
}

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 React, {type ReactNode} from 'react';
import TitleFormatterProvider from '@theme/ThemeProvider/TitleFormatter';
import type {Props} from '@theme/ThemeProvider';
export default function ThemeProvider({children}: Props): ReactNode {
return <TitleFormatterProvider>{children}</TitleFormatterProvider>;
}

View file

@ -43,7 +43,10 @@ export {
export {DEFAULT_SEARCH_TAG} from './utils/searchUtils'; export {DEFAULT_SEARCH_TAG} from './utils/searchUtils';
export {useTitleFormatter} from './utils/generalUtils'; export {
TitleFormatterProvider,
useTitleFormatter,
} from './utils/titleFormatterUtils';
export {useLocationChange} from './utils/useLocationChange'; 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 Head from '@docusaurus/Head';
import useRouteContext from '@docusaurus/useRouteContext'; import useRouteContext from '@docusaurus/useRouteContext';
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl'; import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import {useTitleFormatter} from './generalUtils'; import {useTitleFormatter} from './titleFormatterUtils';
type PageMetadataProps = { type PageMetadataProps = {
readonly title?: string; readonly title?: string;
@ -20,6 +20,55 @@ type PageMetadataProps = {
readonly children?: ReactNode; 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. * Helper component to manipulate page metadata and override site defaults.
* Works in the same way as Helmet. * Works in the same way as Helmet.
@ -31,33 +80,14 @@ export function PageMetadata({
image, image,
children, children,
}: PageMetadataProps): ReactNode { }: PageMetadataProps): ReactNode {
const pageTitle = useTitleFormatter(title);
const {withBaseUrl} = useBaseUrlUtils();
const pageImage = image ? withBaseUrl(image, {absolute: true}) : undefined;
return ( return (
<Head> <>
{title && <title>{pageTitle}</title>} {title && <TitleMetadata title={title} />}
{title && <meta property="og:title" content={pageTitle} />} {description && <DescriptionMetadata description={description} />}
{keywords && <KeywordsMetadata keywords={keywords} />}
{description && <meta name="description" content={description} />} {image && <ImageMetadata image={image} />}
{description && <meta property="og:description" content={description} />} {children && <Head>{children}</Head>}
</>
{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>
); );
} }

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

View file

@ -25,11 +25,11 @@ import Link from '@docusaurus/Link';
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client'; import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import { import {
HtmlClassNameProvider, HtmlClassNameProvider,
PageMetadata,
useEvent, useEvent,
usePluralForm, usePluralForm,
useSearchQueryString, useSearchQueryString,
} from '@docusaurus/theme-common'; } from '@docusaurus/theme-common';
import {useTitleFormatter} from '@docusaurus/theme-common/internal';
import Translate, {translate} from '@docusaurus/Translate'; import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import { import {
@ -160,6 +160,25 @@ type ResultDispatcher =
| {type: 'update'; value: ResultDispatcherState} | {type: 'update'; value: ResultDispatcherState}
| {type: 'advance'; value?: undefined}; | {type: 'advance'; value?: undefined};
function getSearchPageTitle(searchQuery: string | undefined): string {
return searchQuery
? translate(
{
id: 'theme.SearchPage.existingResultsTitle',
message: 'Search results for "{query}"',
description: 'The search page title for non-empty query',
},
{
query: searchQuery,
},
)
: translate({
id: 'theme.SearchPage.emptyResultsTitle',
message: 'Search the documentation',
description: 'The search page title for empty query',
});
}
function SearchPageContent(): ReactNode { function SearchPageContent(): ReactNode {
const { const {
i18n: {currentLocale}, i18n: {currentLocale},
@ -167,12 +186,13 @@ function SearchPageContent(): ReactNode {
const { const {
algolia: {appId, apiKey, indexName, contextualSearch}, algolia: {appId, apiKey, indexName, contextualSearch},
} = useAlgoliaThemeConfig(); } = useAlgoliaThemeConfig();
const processSearchResultUrl = useSearchResultUrlProcessor(); const processSearchResultUrl = useSearchResultUrlProcessor();
const documentsFoundPlural = useDocumentsFoundPlural(); const documentsFoundPlural = useDocumentsFoundPlural();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const [searchQuery, setSearchQuery] = useSearchQueryString(); const [searchQuery, setSearchQuery] = useSearchQueryString();
const pageTitle = getSearchPageTitle(searchQuery);
const initialSearchResultState: ResultDispatcherState = { const initialSearchResultState: ResultDispatcherState = {
items: [], items: [],
query: null, query: null,
@ -310,24 +330,6 @@ function SearchPageContent(): ReactNode {
), ),
); );
const getTitle = () =>
searchQuery
? translate(
{
id: 'theme.SearchPage.existingResultsTitle',
message: 'Search results for "{query}"',
description: 'The search page title for non-empty query',
},
{
query: searchQuery,
},
)
: translate({
id: 'theme.SearchPage.emptyResultsTitle',
message: 'Search the documentation',
description: 'The search page title for empty query',
});
const makeSearch = useEvent((page: number = 0) => { const makeSearch = useEvent((page: number = 0) => {
if (contextualSearch) { if (contextualSearch) {
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default'); algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
@ -380,8 +382,9 @@ function SearchPageContent(): ReactNode {
return ( return (
<Layout> <Layout>
<PageMetadata title={pageTitle} />
<Head> <Head>
<title>{useTitleFormatter(getTitle())}</title>
{/* {/*
We should not index search pages We should not index search pages
See https://github.com/facebook/docusaurus/pull/3233 See https://github.com/facebook/docusaurus/pull/3233
@ -390,7 +393,7 @@ function SearchPageContent(): ReactNode {
</Head> </Head>
<div className="container margin-vert--lg"> <div className="container margin-vert--lg">
<Heading as="h1">{getTitle()}</Heading> <Heading as="h1">{pageTitle}</Heading>
<form className="row" onSubmit={(e) => e.preventDefault()}> <form className="row" onSubmit={(e) => e.preventDefault()}>
<div <div

View file

@ -12,6 +12,7 @@ import routes from '@generated/routes';
import {useLocation} from '@docusaurus/router'; import {useLocation} from '@docusaurus/router';
import renderRoutes from '@docusaurus/renderRoutes'; import renderRoutes from '@docusaurus/renderRoutes';
import Root from '@theme/Root'; import Root from '@theme/Root';
import ThemeProvider from '@theme/ThemeProvider';
import SiteMetadata from '@theme/SiteMetadata'; import SiteMetadata from '@theme/SiteMetadata';
import normalizeLocation from './normalizeLocation'; import normalizeLocation from './normalizeLocation';
import {BrowserContextProvider} from './browserContext'; import {BrowserContextProvider} from './browserContext';
@ -43,10 +44,12 @@ export default function App(): ReactNode {
<DocusaurusContextProvider> <DocusaurusContextProvider>
<BrowserContextProvider> <BrowserContextProvider>
<Root> <Root>
<SiteMetadataDefaults /> <ThemeProvider>
<SiteMetadata /> <SiteMetadataDefaults />
<BaseUrlIssueBanner /> <SiteMetadata />
<AppNavigation /> <BaseUrlIssueBanner />
<AppNavigation />
</ThemeProvider>
</Root> </Root>
<HasHydratedDataAttribute /> <HasHydratedDataAttribute />
</BrowserContextProvider> </BrowserContextProvider>

View file

@ -0,0 +1,20 @@
/**
* 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} from 'react';
import type {Props} from '@theme/ThemeProvider';
// Wrapper component expected to be implemented by a theme
// Unlike <Layout>, it applies to all sites routes and never unmounts
//
// Unlike <Root>, the theme is expected to provide an implementation
// <Root> is empty and the implementation is expected to be provided by the user
//
// Tree order: Root > ThemeProvider > Layout
export default function ThemeProvider({children}: Props): ReactNode {
return <>{children}</>;
}

View file

@ -35,6 +35,7 @@ exports[`base webpack config creates webpack aliases 1`] = `
"@theme-original/PluginThemeComponentOverriddenByUser": "pluginThemeFolder/PluginThemeComponentOverriddenByUser.js", "@theme-original/PluginThemeComponentOverriddenByUser": "pluginThemeFolder/PluginThemeComponentOverriddenByUser.js",
"@theme-original/Root": "../../../../client/theme-fallback/Root/index.tsx", "@theme-original/Root": "../../../../client/theme-fallback/Root/index.tsx",
"@theme-original/SiteMetadata": "../../../../client/theme-fallback/SiteMetadata/index.tsx", "@theme-original/SiteMetadata": "../../../../client/theme-fallback/SiteMetadata/index.tsx",
"@theme-original/ThemeProvider": "../../../../client/theme-fallback/ThemeProvider/index.tsx",
"@theme-original/subfolder/PluginThemeComponent2": "pluginThemeFolder/subfolder/PluginThemeComponent2.js", "@theme-original/subfolder/PluginThemeComponent2": "pluginThemeFolder/subfolder/PluginThemeComponent2.js",
"@theme/Error": "../../../../client/theme-fallback/Error/index.tsx", "@theme/Error": "../../../../client/theme-fallback/Error/index.tsx",
"@theme/Layout": "../../../../client/theme-fallback/Layout/index.tsx", "@theme/Layout": "../../../../client/theme-fallback/Layout/index.tsx",
@ -45,6 +46,7 @@ exports[`base webpack config creates webpack aliases 1`] = `
"@theme/PluginThemeComponentOverriddenByUser": "src/theme/PluginThemeComponentOverriddenByUser.js", "@theme/PluginThemeComponentOverriddenByUser": "src/theme/PluginThemeComponentOverriddenByUser.js",
"@theme/Root": "../../../../client/theme-fallback/Root/index.tsx", "@theme/Root": "../../../../client/theme-fallback/Root/index.tsx",
"@theme/SiteMetadata": "../../../../client/theme-fallback/SiteMetadata/index.tsx", "@theme/SiteMetadata": "../../../../client/theme-fallback/SiteMetadata/index.tsx",
"@theme/ThemeProvider": "../../../../client/theme-fallback/ThemeProvider/index.tsx",
"@theme/UserThemeComponent1": "src/theme/UserThemeComponent1.js", "@theme/UserThemeComponent1": "src/theme/UserThemeComponent1.js",
"@theme/subfolder/PluginThemeComponent2": "pluginThemeFolder/subfolder/PluginThemeComponent2.js", "@theme/subfolder/PluginThemeComponent2": "pluginThemeFolder/subfolder/PluginThemeComponent2.js",
"@theme/subfolder/UserThemeComponent2": "src/theme/subfolder/UserThemeComponent2.js", "@theme/subfolder/UserThemeComponent2": "src/theme/subfolder/UserThemeComponent2.js",

View file

@ -79,6 +79,10 @@ exports[`loadThemeAliases next alias can override the previous alias 1`] = `
"@theme-original/SiteMetadata", "@theme-original/SiteMetadata",
"<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/SiteMetadata/index.tsx", "<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/SiteMetadata/index.tsx",
], ],
[
"@theme-original/ThemeProvider",
"<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/ThemeProvider/index.tsx",
],
[ [
"@theme/Error", "@theme/Error",
"<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/Error/index.tsx", "<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/Error/index.tsx",
@ -127,5 +131,9 @@ exports[`loadThemeAliases next alias can override the previous alias 1`] = `
"@theme/SiteMetadata", "@theme/SiteMetadata",
"<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/SiteMetadata/index.tsx", "<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/SiteMetadata/index.tsx",
], ],
[
"@theme/ThemeProvider",
"<PROJECT_ROOT>/packages/docusaurus/src/client/theme-fallback/ThemeProvider/index.tsx",
],
] ]
`; `;

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 ComponentProps, type ReactNode} from 'react';
import {TitleFormatterProvider} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/ThemeProvider/TitleFormatter';
type FormatterProp = ComponentProps<typeof TitleFormatterProvider>['formatter'];
const formatter: FormatterProp = (params) => {
// Custom title for dogfood plugin instances
if (params.plugin.id.endsWith('tests')) {
const pluginLabel = `${params.plugin.name.replace(
'docusaurus-plugin-content-',
'',
)} plugin`;
return `🐕 Dogfood - ${pluginLabel}`;
}
// Default title otherwise
return params.defaultFormatter(params);
};
export default function ThemeProviderTitleFormatter({
children,
}: Props): ReactNode {
return (
<TitleFormatterProvider formatter={formatter}>
{children}
</TitleFormatterProvider>
);
}