diff options
author | Suzanne Selhorn <sselhorn@gitlab.com> | 2023-08-15 00:38:11 +0300 |
---|---|---|
committer | Suzanne Selhorn <sselhorn@gitlab.com> | 2023-08-15 00:38:11 +0300 |
commit | fb97a56db704f205050a150e05176e2af4990b4b (patch) | |
tree | c52017f9575f68780167e71f02d2d69d2c5a8664 | |
parent | 87b10d25f454e35af4bcf2f6c9d1d6281209354a (diff) | |
parent | cbffa2a3409ddf27f88ef489bb81f5d9b907a201 (diff) |
Merge branch 'gps-search-result-scroll' into 'main'
Scroll to search query on result pages
See merge request https://gitlab.com/gitlab-org/gitlab-docs/-/merge_requests/4140
Merged-by: Suzanne Selhorn <sselhorn@gitlab.com>
Co-authored-by: Sarah German <sgerman@gitlab.com>
-rw-r--r-- | content/assets/stylesheets/stylesheet.scss | 5 | ||||
-rw-r--r-- | content/frontend/search/components/lunr_results.vue | 7 | ||||
-rw-r--r-- | content/frontend/search/google.js | 3 | ||||
-rw-r--r-- | content/frontend/search/lunrsearch.js | 42 | ||||
-rw-r--r-- | content/frontend/search/search_helpers.js | 55 | ||||
-rw-r--r-- | content/frontend/services/google_search_api.js | 7 | ||||
-rw-r--r-- | spec/frontend/search/search_helper_spec.js | 32 |
7 files changed, 128 insertions, 23 deletions
diff --git a/content/assets/stylesheets/stylesheet.scss b/content/assets/stylesheets/stylesheet.scss index 751c5b18..ebaa60bd 100644 --- a/content/assets/stylesheets/stylesheet.scss +++ b/content/assets/stylesheets/stylesheet.scss @@ -20,6 +20,11 @@ body { } } +h2, +h3, +h4, +h5, +h6, :target { scroll-margin-top: $header-height + 1rem; } diff --git a/content/frontend/search/components/lunr_results.vue b/content/frontend/search/components/lunr_results.vue index 7d147127..f579938d 100644 --- a/content/frontend/search/components/lunr_results.vue +++ b/content/frontend/search/components/lunr_results.vue @@ -1,7 +1,7 @@ <script> /* global lunr */ import { GlSearchBoxByClick, GlLink } from '@gitlab/ui'; -import { getSearchParamsFromURL, updateURLParams } from '../search_helpers'; +import { getSearchParamsFromURL, updateURLParams, searchResultQueryParam } from '../search_helpers'; import { isArchivesSite } from '../../default/environment'; export default { @@ -70,7 +70,10 @@ export default { Object.keys(this.results).forEach((key) => { const contentItem = this.contentMap.find(({ id }) => id === this.results[key].ref); this.results[key].title = contentItem.h1; - this.results[key].link = `/${this.results[key].ref}`; + this.results[key].link = `/${this.results[key].ref}${searchResultQueryParam( + this.query, + this.results[key].ref, + )}`; }); // Rewrite links to include the version prefix if this is the archives site. diff --git a/content/frontend/search/google.js b/content/frontend/search/google.js index 153ef650..b13e6b49 100644 --- a/content/frontend/search/google.js +++ b/content/frontend/search/google.js @@ -1,8 +1,9 @@ /* global Vue */ import GoogleSearchForm from './components/google_search_form.vue'; -import { activateKeyboardShortcut } from './search_helpers'; +import { activateKeyboardShortcut, scrollToQuery } from './search_helpers'; document.addEventListener('DOMContentLoaded', () => { + scrollToQuery(); activateKeyboardShortcut(); const { isHomepage } = document.querySelector('body').dataset; diff --git a/content/frontend/search/lunrsearch.js b/content/frontend/search/lunrsearch.js index 5ca9229a..15c215e9 100644 --- a/content/frontend/search/lunrsearch.js +++ b/content/frontend/search/lunrsearch.js @@ -1,26 +1,30 @@ /* global Vue */ import LunrResults from './components/lunr_results.vue'; import SearchForm from './components/lunr_search_form.vue'; +import { activateKeyboardShortcut, scrollToQuery } from './search_helpers'; -// Search results page (/search) document.addEventListener('DOMContentLoaded', () => { - return new Vue({ - el: '.js-lunrsearch', - render(createElement) { - return createElement(LunrResults); - }, - }); -}); + scrollToQuery(); + activateKeyboardShortcut(); -// Homepage and interior navbar search forms -document.addEventListener('DOMContentLoaded', () => { - return new Vue({ - el: '.js-search-form', - components: { - SearchForm, - }, - render(createElement) { - return createElement(SearchForm); - }, - }); + // Search results page (/search) + (() => + new Vue({ + el: '.js-lunrsearch', + render(createElement) { + return createElement(LunrResults); + }, + }))(); + + // Homepage and interior navbar search forms + (() => + new Vue({ + el: '.js-search-form', + components: { + SearchForm, + }, + render(createElement) { + return createElement(SearchForm); + }, + }))(); }); diff --git a/content/frontend/search/search_helpers.js b/content/frontend/search/search_helpers.js index 744d7302..c4c22b7d 100644 --- a/content/frontend/search/search_helpers.js +++ b/content/frontend/search/search_helpers.js @@ -118,3 +118,58 @@ export const activateKeyboardShortcut = () => { document.querySelector('input[type="search"]').focus(); }); }; + +/** + * Find the highest-level scrollable header that contains a given string. + * + * We need a regex here to match only if there are word boundaries or slashes. + * For example: + * - "to" should match in "login to gitlab" but not "repository" + * - "AI" should match in "AI/ML" but not "FAIL" + */ +export const findHighestHeader = (query) => { + const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + const regex = new RegExp(`(?<=^|\\s|\\/)${query}s?(?=$|\\s|\\/)`, 'gi'); + const matches = Array.from(headings).filter((heading) => heading.textContent.match(regex)); + + if (matches.length) { + return matches.sort((a, b) => a.tagName.localeCompare(b.tagName))[0]; + } + + return null; +}; + +/** + * If a search query is in the URL parameters and a heading, scroll to it. + */ +export const scrollToQuery = () => { + const { qParam } = getSearchParamsFromURL(); + if (!qParam) return; + + const header = findHighestHeader(qParam); + if (header && header.tagName !== 'H1') { + header.scrollIntoView({ behavior: 'smooth' }); + } +}; + +/** + * Generate a query string to be appended to search result links. + * + * This is used to limit which pages we run scrollToQuery() on. + */ +export const searchResultQueryParam = (query, link) => { + const pages = new Set(['/ee/ci/yaml/']); + + // Check if the search result is included in the pages set. + let linkPath = ''; + try { + const url = new URL(link); // Google results are full URLs + linkPath = url.pathname; + } catch { + linkPath = `/${link.replace('/index.html', '/')}`; // Lunr results are just paths + } + + if (pages.has(linkPath)) return `?query=${query}`; + return ''; +}; diff --git a/content/frontend/services/google_search_api.js b/content/frontend/services/google_search_api.js index c0b81b0e..e33fac77 100644 --- a/content/frontend/services/google_search_api.js +++ b/content/frontend/services/google_search_api.js @@ -1,5 +1,7 @@ /* global GOOGLE_SEARCH_KEY */ +import { searchResultQueryParam } from '../search/search_helpers'; + export const GPS_ENDPOINT = 'https://www.googleapis.com/customsearch/v1/siterestrict?'; export const GPS_ID = '97494f9fe316a426d'; export const MAX_RESULTS_PER_PAGE = 10; @@ -37,7 +39,10 @@ export const fetchResults = async (query, filters, pageNumber, numResults) => { .replace(' ...', '') .replaceAll('`', '') .trim(), - relativeLink: item.link.replace('https://docs.gitlab.com/', '/'), + relativeLink: `${item.link.replace('https://docs.gitlab.com/', '/')}${searchResultQueryParam( + query, + item.link, + )}`, breadcrumbs: item.pagemap.metatags[0]['gitlab-docs-breadcrumbs'] ?? '', })); } diff --git a/spec/frontend/search/search_helper_spec.js b/spec/frontend/search/search_helper_spec.js new file mode 100644 index 00000000..9fe01063 --- /dev/null +++ b/spec/frontend/search/search_helper_spec.js @@ -0,0 +1,32 @@ +/** + * @jest-environment jsdom + */ + +import { findHighestHeader } from '../../../content/frontend/search/search_helpers'; + +describe('frontend/search/search_helpers', () => { + beforeEach(() => { + // Create a mock DOM environment + document.body.innerHTML = + '<!DOCTYPE html><html><body><h2>login to gitlab</h2><h3>AI/ML</h3><h4>repository</h4></body></html>'; + }); + + it('should find the correct scrollable header for query "to"', () => { + const result = findHighestHeader('to'); + expect(result).toBeDefined(); + expect(result.textContent).toBe('login to gitlab'); + expect(result.tagName.toLowerCase()).toBe('h2'); + }); + + it('should find the correct scrollable header for query "AI"', () => { + const result = findHighestHeader('AI'); + expect(result).toBeDefined(); + expect(result.textContent).toBe('AI/ML'); + expect(result.tagName.toLowerCase()).toBe('h3'); + }); + + it('should not find any scrollable header for query "FAIL"', () => { + const result = findHighestHeader('FAIL'); + expect(result).toBeNull(); + }); +}); |