From ffebd1790eb45f15d8a95ff25eab9e81f0e80ad0 Mon Sep 17 00:00:00 2001 From: Tomek Date: Fri, 2 Oct 2026 14:07:15 +0200 Subject: [PATCH] Extract page title utility and add SEO title support (#4647) Co-authored-by: Claude Haiku 4.5 Co-authored-by: Nolann Biron Co-authored-by: Nolann B. <100787331+nolannbiron@users.noreply.github.com> --- .changeset/page-tag-title.md | 5 ++ bun.lock | 4 +- package.json | 2 +- .../src/components/SitePage/SitePage.test.ts | 49 +++++++++++++++++++ .../src/components/SitePage/SitePage.tsx | 27 ++-------- .../gitbook/src/components/SitePage/title.ts | 23 +++++++++ 6 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 .changeset/page-tag-title.md create mode 100644 packages/gitbook/src/components/SitePage/SitePage.test.ts create mode 100644 packages/gitbook/src/components/SitePage/title.ts diff --git a/.changeset/page-tag-title.md b/.changeset/page-tag-title.md new file mode 100644 index 000000000..72cc8e4c7 --- /dev/null +++ b/.changeset/page-tag-title.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Use the page's tag title, when set, for the HTML `` of published pages. diff --git a/bun.lock b/bun.lock index 98b2a4052..aa8fd04d6 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ }, "catalog": { "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.203.0", + "@gitbook/api": "0.204.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -727,7 +727,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.203.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-cSFUMM7cIMTHW9cdMpexGSkHHaXmc506fx+LQE2+KLqce8GlBDU/ElIP4RU0MGtN+SSYF31AUgvPD8ZerBVcMA=="], + "@gitbook/api": ["@gitbook/api@0.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 194a469ea..26d42da57 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.203.0", + "@gitbook/api": "0.204.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/SitePage/SitePage.test.ts b/packages/gitbook/src/components/SitePage/SitePage.test.ts new file mode 100644 index 000000000..9ceec6580 --- /dev/null +++ b/packages/gitbook/src/components/SitePage/SitePage.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; + +import type { RevisionPageDocument } from '@gitbook/api'; + +import { getPageFullTitle } from './title'; +import type { GitBookSiteContext } from '@/lib/context'; + +function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext { + return { + site: { title: siteTitle }, + visibleSections: sectionTitle + ? { + current: { title: sectionTitle, default: false }, + list: [{ object: 'site-section' }, { object: 'site-section' }], + } + : undefined, + visibleSiteSpaces: [], + } as unknown as GitBookSiteContext; +} + +function makePage(title: string, tagTitle?: string): RevisionPageDocument { + return { title, tagTitle } as unknown as RevisionPageDocument; +} + +describe('getPageFullTitle', () => { + it('uses tagTitle as the first segment', () => { + expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe( + 'SEO title | GitBook' + ); + }); + + it('falls back to the page title when tagTitle is absent', () => { + expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe( + 'Page title | GitBook' + ); + }); + + it('deduplicates the section title against tagTitle', () => { + expect( + getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section')) + ).toBe('Section | GitBook'); + }); + + it('deduplicates the site title against tagTitle', () => { + expect( + getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title')) + ).toBe('SEO title'); + }); +}); diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 6ab1b65b7..49f313eec 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -4,7 +4,6 @@ import { notFound, redirect } from 'next/navigation'; import { CustomizationDefaultThemeMode, CustomizationHeaderPreset, - type RevisionPageDocument, SiteInsightsDisplayContext, type TranslationLanguage, } from '@gitbook/api'; @@ -18,6 +17,7 @@ import { getPathnameParam, } from './fetch'; import { PageClientLayout } from './PageClientLayout'; +import { getPageFullTitle } from './title'; import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter'; import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript'; import { PageAside } from '@/components/PageAside'; @@ -33,11 +33,7 @@ import { getResizedImageURL } from '@/lib/images'; import { getPagePath } from '@/lib/pages'; import { resolveContentRef } from '@/lib/references'; import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; -import { - getSiteSpacePagePaths, - getSiteStructureTitle, - resolveSiteSpaceCustomHomePage, -} from '@/lib/sites'; +import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites'; import { tcls } from '@/lib/tailwind'; import { generateUpdatesFilterCSS, @@ -45,6 +41,8 @@ import { updatesFilterStyleHref, } from '@/lib/updates'; +export { getPageFullTitle } from './title'; + export type SitePageProps = { context: GitBookSiteContext; pageParams: PagePathParams; @@ -421,20 +419,3 @@ async function resolvePageMetaLinks( alternates: [], }; } - -/** - * Get the <title> for a page. - */ -export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) { - const { site } = context; - const siteStructureTitle = getSiteStructureTitle(context); - - return [ - page.title, - // Prevent duplicate titles by comparing against the page title. - page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it. - page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page. - ] - .filter(Boolean) - .join(' | '); -} diff --git a/packages/gitbook/src/components/SitePage/title.ts b/packages/gitbook/src/components/SitePage/title.ts new file mode 100644 index 000000000..3a4626646 --- /dev/null +++ b/packages/gitbook/src/components/SitePage/title.ts @@ -0,0 +1,23 @@ +import type { RevisionPageDocument } from '@gitbook/api'; + +import type { GitBookSiteContext } from '@/lib/context'; +import { getSiteStructureTitle } from '@/lib/sites'; + +/** + * Get the <title> for a page. + */ +export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) { + const { site } = context; + const siteStructureTitle = getSiteStructureTitle(context); + const tagTitle = page.tagTitle || page.title; + + return [ + tagTitle, + // The first page of a section is often the same as the section title, so we don't need to show it. + tagTitle !== siteStructureTitle ? siteStructureTitle : null, + // The site title can also be the same as the page title on the site's landing page. + tagTitle !== site.title ? site.title : null, + ] + .filter(Boolean) + .join(' | '); +}