mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Fix search results linking to section anchors on title matches
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. Adds a pure `isPageTitleMatch` helper (mirroring the substring title notion in reciprocalRankFusion) and uses it in SearchPageResultItem to choose the link target. Recovers the approach from the previously-closed draft PR #4434 for RND-11916. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B1rctbY7mEtGP1ZwVowxoE
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user