diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts
index d005b05806..fd6c864980 100644
--- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts
+++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts
@@ -1569,6 +1569,17 @@ declare module '@theme/ThemedImage' {
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' {
import {Details, type DetailsProps} from '@docusaurus/theme-common/Details';
diff --git a/packages/docusaurus-theme-classic/src/theme/ThemeProvider/TitleFormatter/index.tsx b/packages/docusaurus-theme-classic/src/theme/ThemeProvider/TitleFormatter/index.tsx
new file mode 100644
index 0000000000..372d0e3f25
--- /dev/null
+++ b/packages/docusaurus-theme-classic/src/theme/ThemeProvider/TitleFormatter/index.tsx
@@ -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
['formatter'];
+
+const formatter: FormatterProp = (params) => {
+ // Add your own title formatting logic here!
+ return params.defaultFormatter(params);
+};
+
+export default function ThemeProviderTitleFormatter({
+ children,
+}: Props): ReactNode {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/docusaurus-theme-classic/src/theme/ThemeProvider/index.tsx b/packages/docusaurus-theme-classic/src/theme/ThemeProvider/index.tsx
new file mode 100644
index 0000000000..61f7faad0a
--- /dev/null
+++ b/packages/docusaurus-theme-classic/src/theme/ThemeProvider/index.tsx
@@ -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 {children};
+}
diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts
index ca12647f23..b1c116a0c6 100644
--- a/packages/docusaurus-theme-common/src/internal.ts
+++ b/packages/docusaurus-theme-common/src/internal.ts
@@ -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';
diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/generalUtils.test.tsx b/packages/docusaurus-theme-common/src/utils/__tests__/generalUtils.test.tsx
deleted file mode 100644
index 02c2ad5519..0000000000
--- a/packages/docusaurus-theme-common/src/utils/__tests__/generalUtils.test.tsx
+++ /dev/null
@@ -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}) => (
- {children}
- ),
- }).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');
- });
-});
diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/titleFormatterUtils.test.tsx b/packages/docusaurus-theme-common/src/utils/__tests__/titleFormatterUtils.test.tsx
new file mode 100644
index 0000000000..3ede353b77
--- /dev/null
+++ b/packages/docusaurus-theme-common/src/utils/__tests__/titleFormatterUtils.test.tsx
@@ -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 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');
+ });
+});
diff --git a/packages/docusaurus-theme-common/src/utils/generalUtils.ts b/packages/docusaurus-theme-common/src/utils/generalUtils.ts
deleted file mode 100644
index c6732b82cc..0000000000
--- a/packages/docusaurus-theme-common/src/utils/generalUtils.ts
+++ /dev/null
@@ -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;
-}
diff --git a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx
index 110753a84e..49eeedc93d 100644
--- a/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx
+++ b/packages/docusaurus-theme-common/src/utils/metadataUtils.tsx
@@ -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 (
+
+ {formattedTitle}
+
+
+ );
+}
+
+function DescriptionMetadata({description}: {description: string}) {
+ return (
+
+
+
+
+ );
+}
+
+function ImageMetadata({image}: {image: string}) {
+ const {withBaseUrl} = useBaseUrlUtils();
+ const pageImage = withBaseUrl(image, {absolute: true});
+ return (
+
+
+
+
+ );
+}
+
+function KeywordsMetadata({
+ keywords,
+}: {
+ keywords: PageMetadataProps['keywords'];
+}) {
+ return (
+
+
+
+ );
+}
+
/**
* 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 (
-
- {title && {pageTitle}}
- {title && }
-
- {description && }
- {description && }
-
- {keywords && (
-
- )}
-
- {pageImage && }
- {pageImage && }
-
- {children}
-
+ <>
+ {title && }
+ {description && }
+ {keywords && }
+ {image && }
+ {children && {children}}
+ >
);
}
diff --git a/packages/docusaurus-theme-common/src/utils/titleFormatterUtils.tsx b/packages/docusaurus-theme-common/src/utils/titleFormatterUtils.tsx
new file mode 100644
index 0000000000..e3363ad511
--- /dev/null
+++ b/packages/docusaurus-theme-common/src/utils/titleFormatterUtils.tsx
@@ -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:
+ * -
+ * - 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(
+ null,
+);
+
+export function TitleFormatterProvider({
+ formatter,
+ children,
+}: {
+ children: ReactNode;
+ formatter: TitleFormatterFnWithDefault;
+}): ReactNode {
+ return (
+
+ {children}
+
+ );
+}
+
+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,
+ }),
+ };
+}
diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx
index 7e392fee20..0dd822f6e8 100644
--- a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx
+++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx
@@ -25,11 +25,11 @@ import Link from '@docusaurus/Link';
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import {
HtmlClassNameProvider,
+ PageMetadata,
useEvent,
usePluralForm,
useSearchQueryString,
} from '@docusaurus/theme-common';
-import {useTitleFormatter} from '@docusaurus/theme-common/internal';
import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {
@@ -160,6 +160,25 @@ type ResultDispatcher =
| {type: 'update'; value: ResultDispatcherState}
| {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 {
const {
i18n: {currentLocale},
@@ -167,12 +186,13 @@ function SearchPageContent(): ReactNode {
const {
algolia: {appId, apiKey, indexName, contextualSearch},
} = useAlgoliaThemeConfig();
-
const processSearchResultUrl = useSearchResultUrlProcessor();
const documentsFoundPlural = useDocumentsFoundPlural();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const [searchQuery, setSearchQuery] = useSearchQueryString();
+ const pageTitle = getSearchPageTitle(searchQuery);
+
const initialSearchResultState: ResultDispatcherState = {
items: [],
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) => {
if (contextualSearch) {
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
@@ -380,8 +382,9 @@ function SearchPageContent(): ReactNode {
return (
+
+
- {useTitleFormatter(getTitle())}
{/*
We should not index search pages
See https://github.com/facebook/docusaurus/pull/3233
@@ -390,7 +393,7 @@ function SearchPageContent(): ReactNode {
-
{getTitle()}
+
{pageTitle}