diff --git a/.changeset/search-title-match-page-top.md b/.changeset/search-title-match-page-top.md new file mode 100644 index 000000000..d9addf7cc --- /dev/null +++ b/.changeset/search-title-match-page-top.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Search results that match a page title now link to the top of the page instead of a mid-page section anchor; section-level matches keep their heading anchor. diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 9b70246bc..3b00fb40b 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -7,6 +7,7 @@ import { Tooltip } from '../primitives'; import { Emoji } from '../primitives/Emoji/Emoji'; import { HighlightQuery } from './HighlightQuery'; import { SearchResultItem } from './SearchResultItem'; +import { isPageTitleMatch } from './isPageTitleMatch'; import type { MergedPageResult } from './reciprocalRankFusion'; import type { ComputedPageResult } from './search-types'; import type { LocalPageResult } from './useLocalSearchResults'; @@ -26,9 +27,12 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const language = useLanguage(); const bestSection = item.type === 'page' ? item.bestSection : undefined; - // When the section snippet is displayed, link to the section anchor so the - // link matches what the user sees. - const href = bestSection?.body ? bestSection.href : 'href' in item ? item.href : item.pathname; + const pageHref = 'href' in item ? item.href : item.pathname; + // Link to the section anchor only when a section snippet is shown AND the + // query is not a title match. Title/page matches link to the top of the page; + // section-level matches keep their heading anchor. + const href = + bestSection?.body && !isPageTitleMatch(query, item.title) ? bestSection.href : pageHref; const emoji = 'emoji' in item ? item.emoji : undefined; const icon = 'icon' in item ? item.icon : undefined; diff --git a/packages/gitbook/src/components/Search/isPageTitleMatch.test.ts b/packages/gitbook/src/components/Search/isPageTitleMatch.test.ts new file mode 100644 index 000000000..4f80238dc --- /dev/null +++ b/packages/gitbook/src/components/Search/isPageTitleMatch.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { isPageTitleMatch } from './isPageTitleMatch'; + +describe('isPageTitleMatch', () => { + it('matches when every query word is a substring of the title', () => { + expect(isPageTitleMatch('EDR Integrations', 'EDR Integrations')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isPageTitleMatch('edr integrations', 'EDR Integrations')).toBe(true); + }); + + it('matches when the title contains the query as part of a longer title', () => { + expect(isPageTitleMatch('EDR', 'EDR Integrations')).toBe(true); + }); + + it('does not match a section-level query whose words are not all in the title', () => { + expect(isPageTitleMatch('selecting crowdstrike URL', 'EDR Integrations')).toBe(false); + }); + + it('requires every query word to appear in the title', () => { + expect(isPageTitleMatch('EDR missing', 'EDR Integrations')).toBe(false); + }); + + it('returns false for an empty query', () => { + expect(isPageTitleMatch('', 'EDR Integrations')).toBe(false); + }); + + it('returns false for a whitespace-only query', () => { + expect(isPageTitleMatch(' ', 'EDR Integrations')).toBe(false); + }); + + it('ignores extra whitespace between query words', () => { + expect(isPageTitleMatch(' EDR Integrations ', 'EDR Integrations')).toBe(true); + }); + + it('matches substrings within a title word (substring heuristic)', () => { + expect(isPageTitleMatch('api', 'Rapid start')).toBe(true); + }); +}); diff --git a/packages/gitbook/src/components/Search/isPageTitleMatch.ts b/packages/gitbook/src/components/Search/isPageTitleMatch.ts new file mode 100644 index 000000000..afa8c5a1d --- /dev/null +++ b/packages/gitbook/src/components/Search/isPageTitleMatch.ts @@ -0,0 +1,24 @@ +/** + * Whether a search query matches a page by its title. + * + * There is no server-provided title-vs-section flag on search results, so we + * infer a title match the same way title scoring does in `reciprocalRankFusion`: + * a match when every whitespace-split query word appears (case-insensitively) + * as a substring of the title. + * + * Used to decide whether a result should link to the top of the page (title + * match) or keep its section anchor (section-level match). + */ +export function isPageTitleMatch(query: string, title: string): boolean { + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return false; + } + + const lowerTitle = title.toLowerCase(); + return queryWords.every((word) => lowerTitle.includes(word)); +}