diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..7dc091115 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +ALGOLIA_APP_ID= +ALGOLIA_API_KEY= +ALGOLIA_INDEX_NAME= +DOCS_BASE_URL= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32338ced5..937593e42 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,3 +23,7 @@ jobs: run: npm ci - name: Build Project run: npm run build + env: + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} + ALGOLIA_INDEX_NAME: ${{ vars.ALGOLIA_INDEX_NAME }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 413da9a32..d2c64d83d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,10 @@ jobs: - name: Build Project run: npm run build + env: + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} + ALGOLIA_INDEX_NAME: ${{ vars.ALGOLIA_INDEX_NAME }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index fb979b908..d6e5672f4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ .env.development.local .env.test.local .env.production.local +.env.algolia + +# Algolia PoC (temporary) +docsearch-config.json +algolia-records.json npm-debug.log* yarn-debug.log* @@ -24,4 +29,7 @@ yarn-error.log* .cursor .vscode .idea + +.env +.env.local .claude diff --git a/docusaurus.config.js b/docusaurus.config.js index 265d4bdd7..570ff11cf 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -17,6 +17,7 @@ module.exports = async function createConfigAsync() { }, }, themes: ['@docusaurus/theme-mermaid'], + clientModules: ['./src/clientModules/searchHighlight.js', './src/clientModules/searchQueryPersist.js'], stylesheets: [ { href: 'https://cdn.jsdelivr.net/npm/katex@0.13.24/dist/katex.min.css', @@ -64,9 +65,23 @@ module.exports = async function createConfigAsync() { href: 'https://github.com/lidofinance', label: 'GitHub', position: 'right', - } + }, ], }, + algolia: { + appId: process.env.ALGOLIA_APP_ID || 'A2HCNXVT4O', + apiKey: process.env.ALGOLIA_API_KEY || '', + indexName: process.env.ALGOLIA_INDEX_NAME || 'dev_LIDO_DOCS', + contextualSearch: false, + searchPagePath: 'search', + searchParameters: { + distinct: 3, + exactOnSingleWordQuery: 'none', + removeWordsIfNoResults: 'lastWords', + ignorePlurals: true, + queryLanguages: ['en'], + }, + }, }, presets: [ [ @@ -86,10 +101,10 @@ module.exports = async function createConfigAsync() { ], ], plugins: [ - [ - require.resolve('@easyops-cn/docusaurus-search-local'), - { indexBlog: false, docsRouteBasePath: '/', indexPages: true }, - ], + // [ + // require.resolve('@easyops-cn/docusaurus-search-local'), + // { indexBlog: false, docsRouteBasePath: '/', indexPages: true }, + // ], [ '@docusaurus/plugin-client-redirects', { @@ -108,10 +123,7 @@ module.exports = async function createConfigAsync() { }, { to: '/run-on-lido/stvaults/tech-documentation/pdg', - from: [ - '/guides/stvaults/pdg', - '/run-on-lido/stvaults/pdg', - ], + from: ['/guides/stvaults/pdg', '/run-on-lido/stvaults/pdg'], }, { to: '/run-on-lido/stvaults/operational-and-management-guides/health-monitoring-guide', @@ -179,5 +191,5 @@ module.exports = async function createConfigAsync() { }, ], ], - }; -}; + } +} diff --git a/scripts/generate-algolia-records.js b/scripts/generate-algolia-records.js new file mode 100644 index 000000000..9554710ac --- /dev/null +++ b/scripts/generate-algolia-records.js @@ -0,0 +1,300 @@ +/** + * Generate DocSearch-compatible JSON records from Docusaurus build output. + * Reads HTML files from ./build/ and outputs algolia-records.json. + * + * Usage: node scripts/generate-algolia-records.js + */ + +const fs = require('fs') +const path = require('path') +const cheerio = require('cheerio') + +const BUILD_DIR = path.join(__dirname, '..', 'build') +const OUTPUT_FILE = path.join(__dirname, '..', 'algolia-records.json') +const BASE_URL = process.env.DOCS_BASE_URL || 'https://docs.lido.fi' + +// Pages to skip (not real doc content) +const SKIP_PATHS = ['/404', '/search', '/assets/', '/img/'] + +/** + * Load sidebar page ordering from Docusaurus build metadata. + * Walks the prev/next linked list to assign sequential positions. + */ +function loadSidebarOrders() { + const orders = new Map() + const metaDir = path.join(__dirname, '..', '.docusaurus', 'docusaurus-plugin-content-docs') + if (!fs.existsSync(metaDir)) return orders + + for (const pluginDir of fs.readdirSync(metaDir, { withFileTypes: true })) { + if (!pluginDir.isDirectory()) continue + const dir = path.join(metaDir, pluginDir.name) + const pages = new Map() + for (const file of fs.readdirSync(dir)) { + if (!file.startsWith('site-') || !file.endsWith('.json')) continue + try { + const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8')) + if (data.permalink && data.sidebar) pages.set(data.permalink.replace(/\/$/, '') || '/', data) + } catch {} + } + let head = null + for (const data of pages.values()) { + if (!data.previous) { + head = data + break + } + } + if (!head) continue + let pos = 0, + current = head + while (current) { + const key = current.permalink.replace(/\/$/, '') || '/' + if (orders.has(key)) break // cycle guard + orders.set(key, pos++) + const np = current.next?.permalink + current = np ? pages.get(np.replace(/\/$/, '') || '/') : null + } + } + console.log(`Loaded sidebar positions for ${orders.size} pages`) + return orders +} + +/** + * Determine lvl0 category from URL path. + */ +function getLvl0(urlPath) { + if (urlPath.startsWith('/run-on-lido/stvaults')) return 'stVaults' + if (urlPath.startsWith('/run-on-lido/csm')) return 'Community Staking Module' + if (urlPath.startsWith('/run-on-lido/node-operators')) return 'Node Operators' + if (urlPath.startsWith('/run-on-lido')) return 'Run on Lido' + if (urlPath.startsWith('/contracts')) return 'Contracts' + if (urlPath.startsWith('/guides')) return 'Guides' + if (urlPath.startsWith('/integrations')) return 'Integrations' + if (urlPath.startsWith('/token-guides')) return 'Token Guides' + if (urlPath.startsWith('/security')) return 'Security' + if (urlPath.startsWith('/deployed-contracts')) return 'Deployed Contracts' + if (urlPath.startsWith('/staking-modules')) return 'Staking Modules' + if (urlPath.startsWith('/multisigs')) return 'Multisigs' + if (urlPath.startsWith('/ipfs')) return 'IPFS' + if (urlPath.startsWith('/lips')) return 'Lido Improvement Proposals' + return 'Documentation' +} + +/** + * Recursively find all HTML files in a directory. + */ +function findHtmlFiles(dir) { + const results = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...findHtmlFiles(fullPath)) + } else if (entry.name.endsWith('.html')) { + results.push(fullPath) + } + } + return results +} + +// DocSearch weight.level values (higher = more important in ranking) +const LEVEL_WEIGHTS = { lvl1: 100, lvl2: 90, lvl3: 80, content: 70 } + +/** + * Compute pageRank from URL depth. + * Shorter paths (parent pages) get higher rank → appear above children. + * / → depth 0 → pageRank 10 + * /contracts → depth 1 → pageRank 8 + * /run-on-lido/stvaults → depth 2 → pageRank 6 + * .../building-guides → depth 3 → pageRank 4 + * .../pooled-staking-product → depth 4 → pageRank 2 + * .../roles-and-permissions → depth 5 → pageRank 0 + */ +function getPageRank(urlPath) { + if (urlPath === '/') return 10 + const depth = urlPath.split('/').filter(Boolean).length + return Math.max(0, 10 - depth * 2) +} + +/** + * Content-type score: guides rank above contract API references. + * Higher value = shown first when standard ranking criteria tie. + */ +function getContentType(urlPath) { + if (urlPath.startsWith('/run-on-lido/')) return 200 + const segments = urlPath.split('/').filter(Boolean) + if (segments.includes('contracts')) return 50 + if (segments.some((s) => s.includes('guide'))) return 180 + return 100 +} + +/** + * Clean text: collapse whitespace, trim. + */ +function cleanText(text) { + return text.replace(/\s+/g, ' ').trim() +} + +/** + * Extract the text from a heading element, excluding the hash-link anchor. + */ +function getHeadingText($, el) { + const $el = $(el).clone() + $el.find('.hash-link').remove() + return cleanText($el.text()) +} + +/** + * Process a single HTML file and return DocSearch records. + */ +function processHtmlFile(filePath) { + const records = [] + const relativePath = path.relative(BUILD_DIR, filePath) + // Convert file path to URL path: "contracts/lido/index.html" -> "/contracts/lido" + let urlPath = '/' + relativePath.replace(/\\/g, '/').replace(/\/index\.html$/, '') + if (urlPath === '/index') urlPath = '/' + // remove trailing .html for non-index files + urlPath = urlPath.replace(/\.html$/, '') + + // Check if we should skip this page + if (SKIP_PATHS.some((skip) => urlPath.startsWith(skip))) return records + + const html = fs.readFileSync(filePath, 'utf-8') + const $ = cheerio.load(html) + + const article = $('article') + if (article.length === 0) return records + + const fullUrl = BASE_URL + urlPath + const lvl0 = getLvl0(urlPath) + + // Get page title from h1 + const h1 = article.find('h1').first() + const pageTitle = h1.length ? getHeadingText($, h1) : '' + if (!pageTitle) return records + + let position = 0 + + function makeRecord(type, anchor, url, content, hierarchy) { + return { + objectID: `${urlPath}${anchor ? `-${anchor}` : ''}-${position}`, + url, + url_without_anchor: fullUrl, + content, + type, + anchor, + hierarchy, + customRank_contentType: getContentType(urlPath), + customRank_pageRank: getPageRank(urlPath), + customRank_level: LEVEL_WEIGHTS[type] || 70, + customRank_position: position++, + customRank_sidebarPosition: sidebarOrders.get(urlPath) ?? 999, + } + } + + // Record for the page title itself (lvl1) + records.push( + makeRecord('lvl1', null, fullUrl, null, { + lvl0, + lvl1: pageTitle, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }), + ) + + // Walk through the article content after h1 + const contentArea = article.find('.theme-doc-markdown') + if (contentArea.length === 0) return records + + let currentH2 = null + let currentH3 = null + let currentH2Anchor = null + let currentH3Anchor = null + + contentArea.children().each((_, el) => { + const $el = $(el) + const tag = el.tagName?.toLowerCase() + + if (tag === 'header') return // skip h1 header wrapper + + if (tag === 'h2') { + currentH2 = getHeadingText($, el) + currentH2Anchor = $el.attr('id') || null + currentH3 = null + currentH3Anchor = null + + records.push( + makeRecord('lvl2', currentH2Anchor, currentH2Anchor ? `${fullUrl}#${currentH2Anchor}` : fullUrl, null, { + lvl0, + lvl1: pageTitle, + lvl2: currentH2, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }), + ) + return + } + + if (tag === 'h3') { + currentH3 = getHeadingText($, el) + currentH3Anchor = $el.attr('id') || null + + records.push( + makeRecord('lvl3', currentH3Anchor, currentH3Anchor ? `${fullUrl}#${currentH3Anchor}` : fullUrl, null, { + lvl0, + lvl1: pageTitle, + lvl2: currentH2, + lvl3: currentH3, + lvl4: null, + lvl5: null, + lvl6: null, + }), + ) + return + } + + // For content elements: p, ul, ol, table, div (admonitions) + if (['p', 'ul', 'ol', 'table', 'div'].includes(tag)) { + const text = cleanText($el.text()) + if (!text || text.length < 3) return + + const anchor = currentH3Anchor || currentH2Anchor || null + const url = anchor ? `${fullUrl}#${anchor}` : fullUrl + + records.push( + makeRecord('content', anchor, url, text.slice(0, 2000), { + lvl0, + lvl1: pageTitle, + lvl2: currentH2, + lvl3: currentH3, + lvl4: null, + lvl5: null, + lvl6: null, + }), + ) + } + }) + + return records +} + +// Main +const sidebarOrders = loadSidebarOrders() +const htmlFiles = findHtmlFiles(BUILD_DIR) +console.log(`Found ${htmlFiles.length} HTML files in build/`) + +let allRecords = [] +let skipped = 0 + +for (const file of htmlFiles) { + const records = processHtmlFile(file) + if (records.length === 0) skipped++ + allRecords = allRecords.concat(records) +} + +fs.writeFileSync(OUTPUT_FILE, JSON.stringify(allRecords, null, 2)) +console.log(`Generated ${allRecords.length} records (skipped ${skipped} files)`) +console.log(`Output: ${OUTPUT_FILE}`) diff --git a/src/clientModules/search-rules.md b/src/clientModules/search-rules.md new file mode 100644 index 000000000..af28cacf2 --- /dev/null +++ b/src/clientModules/search-rules.md @@ -0,0 +1,406 @@ +## Search Rules + +### Rule 1: A match in the page title outranks a match in the category name + +**In plain terms:** If a user searches for "ipfs", a page titled "About IPFS" should rank higher than a page titled "Security" that merely belongs to the IPFS category. The page title is the most precise relevance indicator. + +**Configuration:** `searchableAttributes` — move `hierarchy.lvl1` (page title) to the FIRST position, above `hierarchy.lvl0` (category). + +**Where:** Algolia Dashboard → Configuration → Searchable attributes + +**Why it works:** The `attribute` ranking criterion compares which attribute the match occurred in. The attribute listed higher in `searchableAttributes` wins. "About IPFS" matches in lvl1 (position 0), "Security" matches only in lvl0 (position 1). + +--- + +### Rule 2: Singular and plural forms return the same results + +**In plain terms:** Searching "contract" and "contracts" should return the same results in the same order. No category should dominate just because its name happens to match the exact plural form. + +**Configuration:** `exactOnSingleWordQuery` = `"none"` + `ignorePlurals` = `true` + `queryLanguages` = `["en"]` + +**Where:** `docusaurus.config.js` → `searchParameters` + +**Why it works:** `exactOnSingleWordQuery: "none"` disables the exact-match bonus for single-word queries. Without this, the `exact` ranking criterion gave a disproportionate boost to records where the query matched the FULL attribute value (e.g., "contracts" exactly matched category `"Contracts"`, pushing all other categories out of sight). With `"none"`, single-word ranking relies on the remaining criteria: `words → typo → attribute → proximity → custom`. `ignorePlurals: true` with `queryLanguages: ["en"]` normalizes English plural/singular forms so both return identical results. + +--- + +### Rule 3: Records matching more query words always come first + +**In plain terms:** If a user searches for "staking router", records containing BOTH words always beat records containing only one of them. The number of matched words is the most important criterion. + +**Configuration:** The `words` criterion is FIRST in `ranking`. + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting + +--- + +### Rule 4: Exact spelling beats typos + +**In plain terms:** A search for "lido" should show exact matches before "lado" or "lid". However, typo-tolerant results should still be found. + +**Configuration:** The `typo` criterion is third in `ranking` (after words and filters). Typo-tolerance is enabled with defaults: 1 typo for words of 4+ characters, 2 typos for words of 8+ characters. + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting + Typo-tolerance + +--- + +### Rule 5: A match in a higher hierarchy level wins + +**In plain terms:** If a word is found in the page title, that is more important than finding it in an h2 subheading. An h2 is more important than h3. An h3 is more important than regular paragraph text. + +**Configuration:** The order of attributes in `searchableAttributes` + the `attribute` criterion in ranking. + +**Where:** Algolia Dashboard → Configuration → Searchable attributes + +**Order:** +``` +hierarchy.lvl1 (unordered) — page title (h1) +hierarchy.lvl0 (unordered) — category +hierarchy.lvl2 (unordered) — h2 subheading +hierarchy.lvl3 (unordered) — h3 subheading +hierarchy.lvl4 (unordered) +hierarchy.lvl5 (unordered) +hierarchy.lvl6 (unordered) +content (unordered) — paragraph text +``` + +--- + +### Rule 6: Words close together beat words spread apart + +**In plain terms:** If a user searches for "staking router", a record where "staking router" appears together in a single sentence should rank higher than a record where "staking" is in one paragraph and "router" is in another. + +**Configuration:** The `proximity` criterion in `ranking`. + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting + +--- + +### Rule 7: Top-level pages outrank deeply nested ones (within the same content type) + +**In plain terms:** Within the same content type (e.g., two guide pages or two contract pages), a shallower page outranks a deeper one. `/contracts/lido` (depth 2) ranks higher than `/contracts/staking-router` (depth 2, same) or a deeper contract page. Cross-type ranking is handled by Rule 15. + +**Configuration:** `customRanking` includes `customRank_pageRank` (desc). Formula in the script: `pageRank = max(0, 10 - depth * 2)`. + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting → Custom Ranking + +--- + +### Rule 8: Page and section headings outrank plain text (at equal relevance) + +**In plain terms:** All else being equal, a record of type "page title" (h1) is more important than a "section heading" (h2), and h2 is more important than a "content paragraph". The user is more likely looking for a page or section than a specific paragraph. + +**Configuration:** `customRanking` includes `customRank_level` (desc). Values: lvl1=100, lvl2=90, lvl3=80, content=70. + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting → Custom Ranking + +--- + +### Rule 9: Deduplication in modal and on /search page + +**In plain terms:** In the search modal (Ctrl+K), if the "Staking Router" page has 50 places where the search term appears, we show at most 3 records from that page (title + 2 sections). On the `/search` page, results are deduplicated by URL (including anchor): multiple records from the same heading are collapsed into a single result, keeping the highest-ranked one. Different sections of the same page (different anchors) still appear separately. + +**Configuration:** +- Modal: `distinct` = `3` in `docusaurus.config.js` → `searchParameters`. In the Dashboard, `distinct` remains `false` (do NOT change). +- /search page: Client-side URL dedup in swizzled `src/theme/SearchPage/index.tsx` (reducer `update` case). + +**Where:** `docusaurus.config.js` → `searchParameters` (modal) + `src/theme/SearchPage/index.tsx` (/search page) + +--- + +### Rule 10: Within a page, results follow document order + +**In plain terms:** If a single page has multiple matches, those appearing earlier in the document are more important than those appearing later. A section heading "Overview" (at the top) ranks above "Appendix" (at the bottom). + +**Configuration:** `customRanking` includes `customRank_position` (asc). + +**Where:** Algolia Dashboard → Configuration → Ranking and Sorting → Custom Ranking + +--- + +### Rule 11: When there are zero results, make words optional + +**In plain terms:** If a user enters a long query and nothing is found, it is better to show partial matches than an empty page. Algolia should automatically drop words from the query, starting from the last one. + +**Configuration:** `removeWordsIfNoResults` = `"lastWords"` + +**Where:** Algolia Dashboard → Configuration → No results behavior **AND** `docusaurus.config.js` → `searchParameters` + +--- + +### Rule 12: Support for exact phrases in quotes and exclusions + +**In plain terms:** A user can search for `"staking router"` in quotes for an exact phrase, or `staking -router` to exclude a word. This is standard for technical documentation. + +**Configuration:** `advancedSyntax` = `true` + +**Where:** Algolia Dashboard → Configuration → Advanced syntax + +--- + +### Rule 13: Ranking is identical in the modal and on /search (except for distinct) + +**In plain terms:** The order of results in the search modal (Ctrl+K) and on the /search page should be consistent. The only difference: the modal deduplicates (max 3 from one page), /search shows everything. + +**Configuration:** Ranking parameters (`searchableAttributes`, `removeWordsIfNoResults`, `advancedSyntax`) are set at the index level (Dashboard). `exactOnSingleWordQuery`, `ignorePlurals`, and `queryLanguages` are set in `docusaurus.config.js` → `searchParameters` (modal only; SearchPage does NOT use them). `distinct` is only in code (modal only). + +**Where:** Algolia Dashboard (ranking) + `docusaurus.config.js` (distinct + duplicate for modal) + +--- + +### Rule 14: Pages higher in the sidebar menu are shown first (at equal relevance) + +**In plain terms:** If two pages from the same documentation section match a search query equally well, the one positioned higher in the sidebar menu should appear first. The sidebar order reflects the logical structure of the documentation — introductory pages come first, specialized ones come later. + +**Configuration:** `customRanking` includes `customRank_sidebarPosition` (asc). Value = the ordinal position of the page in sidebar navigation. Generated in `scripts/generate-algolia-records.js` from `.docusaurus/` metadata by traversing the `previous`/`next` chain. + +**Where:** `scripts/generate-algolia-records.js` (field generation) + Algolia Dashboard → Custom Ranking (position 3) + +**Why it works:** When two pages match the query in the same attribute, have the same `pageRank` and `level`, `customRank_sidebarPosition` becomes the tiebreaker. "About IPFS" (sidebar pos N) beats "Lido IPFS applications" (sidebar pos N+4). "Building Guides" (pos M) beats "Operational and Management Guides" (pos M+K). + +--- + +### Rule 15: User-facing guides outrank contract API references (at equal relevance) + +**In plain terms:** If a user searches for "PDG" or "Predeposit Guarantee", the stVaults guide page should appear before the contract API reference page. Guides explain how to USE a feature; contract pages document the CODE. Most users need the guide. + +**Configuration:** `customRanking` includes `customRank_contentType` (desc) as the FIRST custom ranking field. Values assigned automatically by URL segment patterns in `scripts/generate-algolia-records.js`: +- `/run-on-lido/*` = 200 (operator guides — entire second docs plugin) +- Any URL with a segment containing `guide` = 180 (catches `/guides/*`, `/token-guides/*`, and any future `*guide*` section) +- Default = 100 (general docs) +- Any URL with a segment exactly `contracts` = 50 (catches `/contracts/*`, `/staking-modules/csm/contracts/*`, and any future `*/contracts/*`) + +**Where:** `scripts/generate-algolia-records.js` (field generation) + Algolia Dashboard → Custom Ranking (position 1, desc) + +**Why it works:** When two pages match the same query in the same attribute with equal typo/proximity, `customRank_contentType` is the first tiebreaker. A guide (200) always outranks a contract reference (50). This does NOT affect queries where pages are differentiated by standard criteria (words, typo, attribute, proximity, exact). New sections are auto-classified by naming convention — no manual code changes needed. + +--- + +### Rule 16: Context-aware query persistence in the search modal + +**In plain terms:** The search modal restores the previous query ONLY in these cases: +1. User navigated to a search result (via mouse click or keyboard arrow+Enter) → reopens modal on the result page. +2. User navigated to a search result → went back to the original page → reopens modal. +3. User is on the `/search` page → modal prefills with the current search page query. + +If the user navigates anywhere else (menu, internal link, etc.), the query is NOT restored. Clicking the clear button (X) in the modal also clears the stored query permanently. + +**Configuration:** `src/clientModules/searchQueryPersist.js` saves the query and navigation context (`{ resultUrl, originUrl }`) to `sessionStorage`. Two code paths handle this: +- **Mouse click**: a `click` event listener on `.DocSearch-Hit a` saves the context. +- **Keyboard Enter**: a `keydown` handler saves the context when `userNavigated` is true, reading the selected result URL from `.DocSearch-Hit[aria-selected="true"] a`. + +The `onRouteDidUpdate` lifecycle hook clears storage when the user navigates away from allowed pages. On modal open, the query is restored from the URL `?q=` param (on `/search` page) or from sessionStorage (if context is valid). + +**Where:** `src/clientModules/searchQueryPersist.js` + `docusaurus.config.js` → `clientModules` + +--- + +### Rule 17: CamelCase contract names are searchable as separate words + +**In plain terms:** A search for "staking router" (two words) should match the page titled "StakingRouter" (one camelCase word). Without this, content paragraphs containing "staking" and "router" as separate words would outrank the actual page title. + +**Configuration:** `camelCaseAttributes` in Algolia Dashboard: +``` +hierarchy.lvl0, hierarchy.lvl1, hierarchy.lvl2, hierarchy.lvl3 +``` + +**Where:** Algolia Dashboard → Index Configuration → Language → camelCaseAttributes + +**Why it works:** Algolia splits camelCase tokens into component words at indexing time. "StakingRouter" becomes searchable as "Staking" + "Router" while keeping the original display. The `attribute` ranking criterion then correctly identifies the title record as matching in lvl1 (highest priority). + +--- + +### Rule 18: Enter in search modal opens the search page + +**In plain terms:** When typing in the search modal, pressing Enter navigates to the `/search?q=...` page (like Google). If the user first selects a specific result using arrow keys, Tab, or mouse hover, then Enter navigates to that result instead. The first ArrowDown/ArrowUp/Tab press highlights the first result without skipping to the second. + +**Configuration:** `src/clientModules/searchQueryPersist.js` intercepts `keydown` events in capture phase. Three flags manage the behavior: +- `userNavigated` — tracks whether the user explicitly selected a result (via arrows, Tab, or mouse hover). When `false`, Enter redirects to `/search?q=...`. When `true`, Enter lets DocSearch navigate to the selected result. +- `firstArrowHandled` — ensures the first arrow/Tab press reveals the highlight on the first result (index 0) without advancing to the second. DocSearch internally sets `defaultActiveItemId: 0`, so item 0 is always "active" but visually hidden by the `.no-initial-selection` CSS class. The first press suppresses the event (`preventDefault`) so autocomplete-core doesn't advance from 0 to 1, while removing the CSS class to reveal the highlight. Subsequent presses propagate normally. +- CSS class `.no-initial-selection` on `.DocSearch-Modal` suppresses the visual highlight on the auto-selected first result until the user explicitly navigates. + +**Where:** `src/clientModules/searchQueryPersist.js` (JS behavior) + `src/css/custom.css` (visual suppression) + +--- + +### Rule 19: Search term highlighting on target page + +**In plain terms:** After navigating to a search result (via mouse click or keyboard Enter, from either the DocSearch modal or the `/search` page), matching search terms are highlighted with a yellow background on the target page. The page scrolls to the first match. Highlights fade out after 5 seconds and are removed entirely after 6 seconds. + +**Configuration:** `src/clientModules/searchHighlight.js` receives the search query from two sources: +- **DocSearch modal (mouse)**: a `click` event listener on `.DocSearch-Hit a` saves the query to `sessionStorage['search-highlight-query']`. +- **DocSearch modal (keyboard)**: the `keydown` handler in `searchQueryPersist.js` saves the same key when `userNavigated` is true and Enter is pressed. +- **`/search` page**: on route change, reads the query from the previous location's `?q=` URL param. + +On route change (`onRouteDidUpdate`), the module walks `
` text nodes with `TreeWalker`, wraps case-insensitive matches in `` elements. Skips `pre`, `code`, `.search-highlight`, and `nav` elements. Scrolls to the first match unless the URL has a hash anchor (in which case the anchor takes priority). After 5 seconds, adds `.search-highlight--fade` (CSS transition to transparent), then after 1 more second removes `` elements entirely. + +**Where:** `src/clientModules/searchHighlight.js` + `src/css/custom.css` (`.search-highlight`, `.search-highlight--fade`) + +--- + +## Verification Queries + +Test queries to verify each rule works correctly after changes. Run in both the Ctrl+K modal and on the `/search` page unless stated otherwise. + +### Rule 1: Title > category + +**Query:** `ipfs` +**Check:** Page "About IPFS" (`/ipfs/about`, title match in lvl1) ranks above pages that only belong to the IPFS category but don't have "ipfs" in their title (e.g. "Hash Verification"). + +### Rule 2: Plural normalization + +**Query 1:** `contracts` +**Check:** Results from BOTH "Contracts" and "Deployed Contracts" categories appear (not just "Contracts"). Behavior is consistent with searching `contract` (singular). + +**Query 2:** `guides` +**Check:** Pages from the "Guides" category still rank above pages from "Token Guides" category (differentiated by sidebarPosition and other custom ranking criteria). + +### Rule 3: More matched words first + +**Query:** `staking router` +**Check:** "StakingRouter" page (`/contracts/staking-router`, matches BOTH words) ranks above pages matching only "staking" or only "router". + +### Rule 4: Exact spelling > typos + +**Query:** `lido` +**Check:** Exact matches appear first. Any typo-tolerant results (if present) appear lower. + +### Rule 5: Higher hierarchy level wins + +**Query:** `HashConsensus` +**Check:** Page titled "HashConsensus" (title match in lvl1) ranks above pages where "HashConsensus" only appears in paragraph text (content field). Single word — no proximity ambiguity, isolates the attribute criterion. + +### Rule 6: Proximity + +**Query:** `staking router` +**Check:** After enabling `camelCaseAttributes` (Rule 17): page title "StakingRouter" (words adjacent) ranks above pages where "staking" and "router" appear in different paragraphs. + +### Rule 7: Shallower pages outrank deeper (same content type) + +**Query:** `systemd` +**Check:** All results are within run-on-lido (contentType=200). Shallower CSM pages (depth 4) rank above deeper systemd-specific pages (depth 6), isolating the pageRank criterion. + +### Rule 8: Headings > plain text + +**Query:** `bunker mode` +**Check:** Records where "bunker mode" appears in a heading (lvl2/lvl3, level=90/80) rank above records where it only appears in paragraph text (content, level=70) within the same contentType group. + +### Rule 9: Deduplication + +**Query:** `cou` (in Ctrl+K modal and on /search page) +**Check (modal):** At most 3 results from any single page (e.g. AccountingOracle shows title + max 2 sections). +**Check (/search):** No duplicate entries with the same title and breadcrumbs. Each unique URL (including anchor) appears only once. + +### Rule 10: Document order within page + +**Query:** `requestWithdrawals` +**Check:** Within the WithdrawalQueueERC721 page results, sections appearing earlier in the document come first. + +### Rule 11: Optional words on zero results + +**Query:** `validator ejector bunker mode configuration settings advanced` +**Check:** Results are shown (Algolia drops trailing words) rather than an empty page. + +### Rule 12: Advanced syntax + +**Query 1:** `"staking router"` (with quotes) +**Check:** Only pages containing the exact phrase appear. + +**Query 2:** `oracle -accounting` +**Check:** Oracle-related pages appear, AccountingOracle page is excluded. + +### Rule 13: Modal = /search ranking + +**Query:** `PDG` (compare both) +**Check:** Same result order in modal and on `/search`. Only difference: modal groups max 3 per page (Rule 9). + +### Rule 14: Sidebar position + +**Query:** `oracle` (look at contract pages) +**Check:** "AccountingOracle" (earlier in sidebar) appears before "ValidatorsExitBusOracle" (later in sidebar) when other criteria tie. + +### Rule 15: Guides > contracts (contentType) + +**Query 1:** `PDG` +**Check:** Guide "Predeposit Guarantee" (`/run-on-lido/stvaults/tech-documentation/pdg`, contentType=200) appears above contract "PredepositGuarantee" (`/contracts/predeposit-guarantee`, contentType=50). + +**Query 2:** `CSModule` +**Check:** Run-on-Lido CSM guide pages (contentType=200) appear above contract reference `/staking-modules/csm/contracts/CSModule` (contentType=50). + +**Query 3:** `validator ejector guide` +**Check:** "Validator Ejector Guide" from `/guides/` (contentType=180) ranks above contract pages mentioning ejector (contentType=50). + +### Rule 16: Context-aware query persistence + +**Action 1 (result page):** Search "PDG" → click a result → navigate to target page → open Ctrl+K. +**Check:** "PDG" is still in the input on the result page. + +**Action 2 (back to origin):** Search "PDG" → click a result → press browser Back → open Ctrl+K. +**Check:** "PDG" is still in the input on the original page. + +**Action 3 (navigate away):** Search "PDG" → click a result → click any menu link → open Ctrl+K. +**Check:** Input is EMPTY — query was cleared on unrelated navigation. + +**Action 4 (clear button):** Open Ctrl+K → type "PDG" → click X (clear) → close modal → reopen. +**Check:** Input is EMPTY — clear button permanently removes stored query. + +**Action 5 (search page sync):** Navigate to /search → type "oracle" → open Ctrl+K modal. +**Check:** "oracle" is prefilled in the modal input. + +**Action 6 (keyboard navigation):** Open Ctrl+K → type "PDG" → press ArrowDown → press Enter → navigate to target page → open Ctrl+K. +**Check:** "PDG" is still in the input. + +### Rule 17: CamelCase search + +**Query:** `staking router` +**Check:** Page titled "StakingRouter" appears as first result (requires `camelCaseAttributes` enabled in Dashboard). + +### Rule 18: Enter in search modal opens search page + +**Action 1 (Enter → search page):** Open Ctrl+K → type "staking" → press Enter. +**Check:** Navigates to `/search?q=staking`. + +**Action 2 (arrow + Enter → result):** Open Ctrl+K → type "staking" → press ArrowDown → press Enter. +**Check:** Navigates to the selected search result, NOT the search page. + +**Action 3 (no initial highlight):** Open Ctrl+K → type "staking" → look at results. +**Check:** First result is NOT visually highlighted until user presses arrow keys or hovers. + +**Action 4 (first arrow → first result):** Open Ctrl+K → type "staking" → press ArrowDown once. +**Check:** The FIRST result is highlighted, not the second. + +### Rule 19: Search term highlighting + +**Action 1 (mouse click):** Search "PDG" in modal → click a result. +**Check:** Matching "PDG" terms are highlighted in yellow on the target page, then fade out after 5 seconds. + +**Action 2 (keyboard Enter):** Search "PDG" in modal → press ArrowDown → press Enter. +**Check:** Same yellow highlights appear on the target page. + +**Action 3 (from /search page):** Go to /search?q=PDG → click a result. +**Check:** Same yellow highlights appear on the target page. + +**Action 4 (hash anchor):** Search a term → click a result that links to a specific heading (#anchor). +**Check:** Page scrolls to the anchor, NOT to the first highlight. Highlights still appear but don't override the anchor scroll. + +--- + +### Cross-rule integration tests + +**Test A — contentType vs pageRank:** +Query `predeposit guarantee`. Guide page wins despite deeper URL (contentType 200 > 50 outweighs pageRank difference). Verifies Rules 3, 6, 15. + +**Test B — Multi-category query:** +Query `CSM`. Results from "Community Staking Module" category (guides, contentType=200) appear above "Staking Modules" category (contracts, contentType=50). Within each group, title matches outrank content matches. Verifies Rules 1, 5, 15. + +**Test C — Exact phrase across content types:** +Query `"hash consensus"` (with quotes). Page "HashConsensus" (title match) ranks first even though it's a contract page (contentType=50) — standard criteria (attribute) outweigh custom ranking. Verifies Rules 5, 8, 12. + +**Test D — Deep page with unique term:** +Query `MinFirstAllocationStrategy`. StakingRouter contract page appears — very specific term, one or very few results. Verifies Rules 4, 7, 8. + +**Test E — Highlight after search (Rule 19):** +Search "PDG" in modal → click a result. On the target page, matching terms are highlighted in yellow, then fade out after 5 seconds. Repeat with keyboard: ArrowDown → Enter. Same highlights should appear. Verifies Rule 19. + +**Test F — Keyboard full flow (Rules 16, 18, 19):** +Open Ctrl+K → type "PDG" → press ArrowDown (first result highlights, not second) → press Enter → target page shows yellow highlights → press Ctrl+K → "PDG" is still in the input. Verifies Rules 16, 18, 19 together for the keyboard path. diff --git a/src/clientModules/searchHighlight.js b/src/clientModules/searchHighlight.js new file mode 100644 index 000000000..bcc71b835 --- /dev/null +++ b/src/clientModules/searchHighlight.js @@ -0,0 +1,110 @@ +const STORAGE_KEY = 'search-highlight-query' + +// DocSearch modal: capture query on result click (for highlighting on target page) +if (typeof window !== 'undefined') { + document.addEventListener( + 'click', + (e) => { + if (e.target.closest('.DocSearch-Hit a')) { + const input = document.querySelector('.DocSearch-Input') + if (input?.value) { + sessionStorage.setItem(STORAGE_KEY, input.value.trim()) + } + } + }, + true, + ) +} + +export function onRouteDidUpdate({ location, previousLocation }) { + if (location.pathname.replace(/\/$/, '').endsWith('/search')) return + + // Source 1: DocSearch modal → sessionStorage (consumed immediately) + let query = sessionStorage.getItem(STORAGE_KEY) + if (query) { + sessionStorage.removeItem(STORAGE_KEY) + } + + // Source 2: /search page → previousLocation has ?q= + if (!query && previousLocation?.pathname?.replace(/\/$/, '').endsWith('/search')) { + query = new URLSearchParams(previousLocation.search).get('q')?.trim() + } + + if (!query) return + requestAnimationFrame(() => setTimeout(() => highlightDocPage(query), 100)) +} + +function highlightDocPage(query) { + const root = document.querySelector('article') + if (!root) return + + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + if (!terms.length) return + + const marks = [] + const nodes = [] + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const p = node.parentElement + if (!p || p.closest('pre, code, .search-highlight, nav')) + return NodeFilter.FILTER_REJECT + if (!node.textContent?.trim()) return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_ACCEPT + }, + }) + while (walker.nextNode()) nodes.push(walker.currentNode) + + for (const node of nodes) { + const text = node.textContent + const lower = text.toLowerCase() + const ranges = [] + for (const term of terms) { + let i = 0 + while ((i = lower.indexOf(term, i)) !== -1) { + ranges.push([i, i + term.length]) + i += term.length + } + } + if (!ranges.length) continue + + ranges.sort((a, b) => a[0] - b[0]) + const merged = [ranges[0]] + for (let i = 1; i < ranges.length; i++) { + const last = merged[merged.length - 1] + if (ranges[i][0] <= last[1]) last[1] = Math.max(last[1], ranges[i][1]) + else merged.push(ranges[i]) + } + + const frag = document.createDocumentFragment() + let pos = 0 + for (const [start, end] of merged) { + if (start > pos) + frag.appendChild(document.createTextNode(text.slice(pos, start))) + const mark = document.createElement('mark') + mark.className = 'search-highlight' + mark.textContent = text.slice(start, end) + frag.appendChild(mark) + marks.push(mark) + pos = end + } + if (pos < text.length) + frag.appendChild(document.createTextNode(text.slice(pos))) + node.parentNode.replaceChild(frag, node) + } + + if (!marks.length) return + + if (!window.location.hash) { + marks[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + + setTimeout(() => { + marks.forEach((m) => m.classList.add('search-highlight--fade')) + setTimeout(() => { + marks.forEach((m) => { + const t = document.createTextNode(m.textContent) + m.parentNode?.replaceChild(t, m) + }) + }, 1000) + }, 5000) +} diff --git a/src/clientModules/searchQueryPersist.js b/src/clientModules/searchQueryPersist.js new file mode 100644 index 000000000..d358ad3fb --- /dev/null +++ b/src/clientModules/searchQueryPersist.js @@ -0,0 +1,199 @@ +/** + * DocSearch modal behavior: query persistence, Enter → search page, + * and no-initial-selection management. + * + * Query is only restored when reopening the modal on the page the user + * navigated to from a search result, or on the page where they originally + * opened the modal. Any other navigation clears the stored query. + */ +const PERSIST_KEY = 'docsearch-persist-query' +const CONTEXT_KEY = 'docsearch-persist-context' +const norm = (p) => p.replace(/\/$/, '') + +// Track whether user explicitly selected a result via arrows/tab +let userNavigated = false +let firstArrowHandled = false + +if (typeof window !== 'undefined') { + // ── Save query as user types + reset selection state ───────────── + document.addEventListener( + 'input', + (e) => { + if (!e.target.classList?.contains('DocSearch-Input')) return + const val = e.target.value.trim() + if (val) sessionStorage.setItem(PERSIST_KEY, val) + else sessionStorage.removeItem(PERSIST_KEY) + + userNavigated = false + firstArrowHandled = false + document.querySelector('.DocSearch-Modal')?.classList.add('no-initial-selection') + }, + true, + ) + + // ── Fix #1: Clear button clears sessionStorage ──────────────────── + document.addEventListener( + 'click', + (e) => { + if (e.target.closest('.DocSearch-Clear')) { + sessionStorage.removeItem(PERSIST_KEY) + sessionStorage.removeItem(CONTEXT_KEY) + } + }, + true, + ) + + // ── Fix #2: Save navigation context on result click ─────────────── + document.addEventListener( + 'click', + (e) => { + const link = e.target.closest('.DocSearch-Hit a') + if (!link) return + const input = document.querySelector('.DocSearch-Input') + const query = input?.value?.trim() + if (!query) return + sessionStorage.setItem(PERSIST_KEY, query) + sessionStorage.setItem( + CONTEXT_KEY, + JSON.stringify({ + resultUrl: norm(new URL(link.href, location.origin).pathname), + originUrl: norm(location.pathname), + }), + ) + }, + true, + ) + + // ── Fix #4: Enter → search page / active state management ──────── + document.addEventListener( + 'keydown', + (e) => { + const modal = document.querySelector('.DocSearch-Modal') + if (!modal) return + + if (['ArrowDown', 'ArrowUp', 'Tab'].includes(e.key)) { + userNavigated = true + modal.classList.remove('no-initial-selection') + if (!firstArrowHandled) { + firstArrowHandled = true + e.preventDefault() + e.stopPropagation() + } + return + } + + if (e.key === 'Enter' && !userNavigated) { + const input = document.querySelector('.DocSearch-Input') + const query = input?.value?.trim() + if (!query) return + + e.preventDefault() + e.stopPropagation() + window.location.href = `/search/?q=${encodeURIComponent(query)}` + } + + if (e.key === 'Enter' && userNavigated) { + const input = document.querySelector('.DocSearch-Input') + const query = input?.value?.trim() + if (!query) return + sessionStorage.setItem('search-highlight-query', query) + const hit = modal.querySelector('.DocSearch-Hit[aria-selected="true"] a') + if (!hit) return + sessionStorage.setItem(PERSIST_KEY, query) + sessionStorage.setItem( + CONTEXT_KEY, + JSON.stringify({ + resultUrl: norm(new URL(hit.href, location.origin).pathname), + originUrl: norm(location.pathname), + }), + ) + } + }, + true, + ) + + // Mouse hover on a hit reveals active state + document.addEventListener( + 'mousemove', + (e) => { + if (e.target.closest?.('.DocSearch-Hit')) { + userNavigated = true + firstArrowHandled = true + document.querySelector('.DocSearch-Modal')?.classList.remove('no-initial-selection') + } + }, + true, + ) + + // ── Modal open: restore query + set no-initial-selection ────────── + new MutationObserver(() => { + if (!document.body.classList.contains('DocSearch--active')) return + + userNavigated = false + firstArrowHandled = false + + const fill = () => { + const input = document.querySelector('.DocSearch-Input') + if (!input) return requestAnimationFrame(fill) + if (input.value) return // DocSearch already has initialQuery + + // Fix #3: On /search page, sync from URL ?q= param + let query + if (norm(location.pathname).endsWith('/search')) { + query = new URLSearchParams(location.search).get('q')?.trim() + } + // Otherwise use persisted query + if (!query) { + query = sessionStorage.getItem(PERSIST_KEY) + } + if (!query) return + + const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + set?.call(input, query) + input.dispatchEvent(new Event('input', { bubbles: true })) + } + requestAnimationFrame(fill) + + // Add no-initial-selection class to suppress first-result highlight + const addCls = () => { + const modal = document.querySelector('.DocSearch-Modal') + if (!modal) return requestAnimationFrame(addCls) + modal.classList.add('no-initial-selection') + } + requestAnimationFrame(addCls) + }).observe(document.body, { attributes: true, attributeFilter: ['class'] }) +} + +// ── Fix #2: Clear stored query on navigation away from allowed pages ── +export function onRouteDidUpdate({ location, previousLocation }) { + // When leaving /search page, save query so modal works on the target page + if (previousLocation) { + const prev = norm(previousLocation.pathname) + const curr = norm(location.pathname) + if (prev.endsWith('/search') && !curr.endsWith('/search')) { + const q = new URLSearchParams(previousLocation.search).get('q')?.trim() + if (q) { + sessionStorage.setItem(PERSIST_KEY, q) + sessionStorage.setItem(CONTEXT_KEY, JSON.stringify({ resultUrl: curr, originUrl: prev })) + return + } + } + } + + const raw = sessionStorage.getItem(CONTEXT_KEY) + if (!raw) { + sessionStorage.removeItem(PERSIST_KEY) + return + } + + try { + const { resultUrl, originUrl } = JSON.parse(raw) + const current = norm(location.pathname) + if (current === resultUrl || current === originUrl) return + } catch { + // malformed — clear + } + + sessionStorage.removeItem(PERSIST_KEY) + sessionStorage.removeItem(CONTEXT_KEY) +} diff --git a/src/css/custom.css b/src/css/custom.css index d42b63f14..7a5fb6322 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -378,3 +378,36 @@ .v3-callout-content a:hover { color: #79c0ff; } + +/* Search highlight — target doc page (fades out) */ +.search-highlight { + background-color: rgba(255, 213, 0, 0.4); + border-radius: 2px; + padding: 0 1px; + transition: background-color 1s ease-out; +} + +[data-theme='dark'] .search-highlight { + background-color: rgba(255, 213, 0, 0.3); +} + +.search-highlight--fade { + background-color: transparent; +} + +/* Remove Algolia's default italic on search results — yellow highlight only */ +[class*='searchResultItem'] em { + font-style: normal; + background-color: rgba(255, 213, 0, 0.3); +} + +/* Suppress first-result active highlight until user navigates via arrows/tab/mouse */ +.no-initial-selection .DocSearch-Hit[aria-selected='true'] a { + background-color: var(--docsearch-hit-background); +} + +.no-initial-selection + .DocSearch-Hit[aria-selected='true'] + .DocSearch-Hit-Select-Icon { + display: none; +} diff --git a/src/theme/SearchPage/index.tsx b/src/theme/SearchPage/index.tsx new file mode 100644 index 000000000..3ffe6e1f7 --- /dev/null +++ b/src/theme/SearchPage/index.tsx @@ -0,0 +1,496 @@ +/** + * Swizzled from @docusaurus/theme-search-algolia to add: + * - URL-based deduplication of search results + * - Content snippets from Algolia + * - All results loaded at once (no infinite scroll) + * + * 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. + */ + +/* eslint-disable jsx-a11y/no-autofocus */ + +import React, { type ReactNode, useEffect, useMemo, useReducer, useState } from 'react' +import clsx from 'clsx' + +import algoliaSearchHelper from 'algoliasearch-helper' +import { liteClient } from 'algoliasearch/lite' + +import Head from '@docusaurus/Head' +import Link from '@docusaurus/Link' +import { useAllDocsData } from '@docusaurus/plugin-content-docs/client' +import { + HtmlClassNameProvider, + PageMetadata, + useEvent, + usePluralForm, + useSearchQueryString, +} from '@docusaurus/theme-common' +import Translate, { translate } from '@docusaurus/Translate' +import useDocusaurusContext from '@docusaurus/useDocusaurusContext' +import { useAlgoliaThemeConfig, useSearchResultUrlProcessor } from '@docusaurus/theme-search-algolia/client' +import Layout from '@theme/Layout' +import Heading from '@theme/Heading' +import styles from './styles.module.css' + +// Very simple pluralization: probably good enough for now +function useDocumentsFoundPlural() { + const { selectMessage } = usePluralForm() + return (count: number) => + selectMessage( + count, + translate( + { + id: 'theme.SearchPage.documentsFound.plurals', + description: + 'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: 'One document found|{count} documents found', + }, + { count }, + ), + ) +} + +function useDocsSearchVersionsHelpers() { + const allDocsData = useAllDocsData() + + // State of the version select menus / algolia facet filters + // docsPluginId -> versionName map + const [searchVersions, setSearchVersions] = useState<{ + [pluginId: string]: string + }>(() => + Object.entries(allDocsData).reduce( + (acc, [pluginId, pluginData]) => ({ + ...acc, + [pluginId]: pluginData.versions[0]!.name, + }), + {}, + ), + ) + + // Set the value of a single select menu + const setSearchVersion = (pluginId: string, searchVersion: string) => + setSearchVersions((s) => ({ ...s, [pluginId]: searchVersion })) + + const versioningEnabled = Object.values(allDocsData).some((docsData) => docsData.versions.length > 1) + + return { + allDocsData, + versioningEnabled, + searchVersions, + setSearchVersion, + } +} + +// We want to display one select per versioned docs plugin instance +function SearchVersionSelectList({ + docsSearchVersionsHelpers, +}: { + docsSearchVersionsHelpers: ReturnType +}) { + const versionedPluginEntries = Object.entries(docsSearchVersionsHelpers.allDocsData) + // Do not show a version select for unversioned docs plugin instances + .filter(([, docsData]) => docsData.versions.length > 1) + + return ( +
+ {versionedPluginEntries.map(([pluginId, docsData]) => { + const labelPrefix = versionedPluginEntries.length > 1 ? `${pluginId}: ` : '' + return ( + + ) + })} +
+ ) +} + +function AlgoliaLogo(): ReactNode { + return ( + + + {/* eslint-disable-next-line @docusaurus/no-untranslated-text */} + + + + + + + + + + + + + ) +} + +type ResultDispatcherState = { + items: { + title: string + url: string + summary: string + breadcrumbs: string[] + }[] + query: string | null + totalResults: number | null + loading: boolean | null +} + +type ResultDispatcher = + | { type: 'reset'; value?: undefined } + | { type: 'loading'; value?: undefined } + | { type: 'update'; value: ResultDispatcherState } + +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 }, + } = useDocusaurusContext() + 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, + totalResults: null, + loading: null, + } + const [searchResultState, searchResultStateDispatcher] = useReducer( + (prevState: ResultDispatcherState, data: ResultDispatcher) => { + switch (data.type) { + case 'reset': { + return initialSearchResultState + } + case 'loading': { + return { ...prevState, loading: true } + } + case 'update': { + if (searchQuery !== data.value.query) { + return prevState + } + return data.value + } + default: + return prevState + } + }, + initialSearchResultState, + ) + + // Deduplicate by URL: keep first (highest-ranked) record per URL, + // enrich with content snippet from a sibling record if available. + const uniqueItems = useMemo(() => { + const urlMap = new Map() + for (const item of searchResultState.items) { + const existing = urlMap.get(item.url) + if (!existing) { + urlMap.set(item.url, item) + } else if (!existing.summary && item.summary) { + urlMap.set(item.url, { ...existing, summary: item.summary }) + } + } + return Array.from(urlMap.values()) + }, [searchResultState.items]) + + // respect settings from the theme config for facets + const disjunctiveFacets = contextualSearch ? ['language', 'docusaurus_tag'] : [] + + const algoliaClient = useMemo(() => liteClient(appId, apiKey), [appId, apiKey]) + const algoliaHelper = useMemo( + () => + algoliaSearchHelper(algoliaClient, indexName, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: why errors happens after upgrading to TS 5.5 ? + hitsPerPage: 1000, + advancedSyntax: true, + disjunctiveFacets, + // CUSTOM: request content snippets so results show matching text excerpts + attributesToSnippet: ['content:30'], + snippetEllipsisText: '…', + }), + [algoliaClient, indexName, disjunctiveFacets], + ) + + useEffect(() => { + const onResult = ({ + results: { query, hits, nbHits }, + }: { + results: { + query: string + hits: Array<{ + url: string + _highlightResult: { hierarchy: { [key: string]: { value: string } } } + _snippetResult?: { content?: { value: string; matchLevel: string } } + }> + nbHits: number + } + }) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({ type: 'reset' }) + return + } + + const sanitizeValue = (value: string) => + value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match') + + const items = hits + .filter( + ({ _snippetResult: snippet }) => + // Keep heading records (no content) and content with actual match + !snippet?.content || snippet.content.matchLevel !== 'none', + ) + .map(({ url, _highlightResult: { hierarchy }, _snippetResult: snippet }) => { + const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key]!.value)) + return { + title: titles.pop()!, + url: processSearchResultUrl(url), + summary: snippet?.content ? sanitizeValue(snippet.content.value) : '', + breadcrumbs: titles, + } + }) + + searchResultStateDispatcher({ + type: 'update', + value: { + items, + query, + totalResults: nbHits, + loading: false, + }, + }) + } + + algoliaHelper.on('result', onResult) + return () => { + algoliaHelper.removeAllListeners() + } + }, [algoliaHelper, processSearchResultUrl]) + + const makeSearch = useEvent(() => { + if (contextualSearch) { + algoliaHelper.clearRefinements() + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default') + algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale) + + Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(([pluginId, searchVersion]) => { + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', `docs-${pluginId}-${searchVersion}`) + }) + } + + algoliaHelper.setQuery(searchQuery).search() + }) + + useEffect(() => { + searchResultStateDispatcher({ type: 'reset' }) + + if (searchQuery) { + searchResultStateDispatcher({ type: 'loading' }) + + const timeoutId = setTimeout(() => { + makeSearch() + }, 300) + + return () => clearTimeout(timeoutId) + } + }, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]) + + return ( + + + + + {/* + We should not index search pages + See https://github.com/facebook/docusaurus/pull/3233 + */} + + + +
+ {pageTitle} + +
e.preventDefault()}> +
+ setSearchQuery(e.target.value)} + value={searchQuery} + autoComplete="off" + autoFocus + /> +
+ + {contextualSearch && docsSearchVersionsHelpers.versioningEnabled && ( + + )} + + +
+
+ {uniqueItems.length > 0 && documentsFoundPlural(uniqueItems.length)} +
+ +
+ + {translate({ + id: 'theme.SearchPage.algoliaLabel', + message: 'Powered by', + description: 'The text explain that the search powered by Algolia', + })} + + + + +
+
+ + {uniqueItems.length > 0 ? ( +
+ {uniqueItems.map(({ title, url, summary, breadcrumbs }) => ( +
+ + + + + {breadcrumbs.length > 0 && ( + + )} + + {summary && ( +

+ )} +

+ ))} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

+ + No results were found + +

+ ), + !!searchResultState.loading &&
, + ] + )} +
+ + ) +} + +export default function SearchPage(): ReactNode { + return ( + + + + ) +} diff --git a/src/theme/SearchPage/styles.module.css b/src/theme/SearchPage/styles.module.css new file mode 100644 index 000000000..29c4389f5 --- /dev/null +++ b/src/theme/SearchPage/styles.module.css @@ -0,0 +1,127 @@ +/** + * 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. + */ + +.searchQueryInput, +.searchVersionInput { + border-radius: var(--ifm-global-radius); + border: 2px solid var(--ifm-toc-border-color); + font: var(--ifm-font-size-base) var(--ifm-font-family-base); + padding: 0.8rem; + width: 100%; + background: var(--docsearch-searchbox-focus-background); + color: var(--docsearch-text-color); + margin-bottom: 0.5rem; + transition: border var(--ifm-transition-fast) ease; +} + +.searchQueryInput:focus, +.searchVersionInput:focus { + border-color: var(--docsearch-primary-color); + outline: none; +} + +.searchQueryInput::placeholder { + color: var(--docsearch-muted-color); +} + +.searchResultsColumn { + font-size: 0.9rem; + font-weight: bold; +} + +.searchLogoColumn { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} + +.searchLogoColumn a { + display: flex; +} + +.searchLogoColumn span { + color: var(--docsearch-muted-color); + font-weight: normal; +} + +.searchResultItem { + padding: 1rem 0; + border-bottom: 1px solid var(--ifm-toc-border-color); +} + +.searchResultItemHeading { + font-weight: 400; + margin-bottom: 0; +} + +.searchResultItemPath { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + --ifm-breadcrumb-separator-size-multiplier: 1; +} + +.searchResultItemSummary { + margin: 0.5rem 0 0; + font-style: italic; +} + +@media only screen and (max-width: 996px) { + .searchQueryColumn { + max-width: 60% !important; + } + + .searchVersionColumn { + max-width: 40% !important; + } + + .searchResultsColumn { + max-width: 60% !important; + } + + .searchLogoColumn { + max-width: 40% !important; + padding-left: 0 !important; + } +} + +@media screen and (max-width: 576px) { + .searchQueryColumn { + max-width: 100% !important; + } + + .searchVersionColumn { + max-width: 100% !important; + padding-left: var(--ifm-spacing-horizontal) !important; + } +} + +.loadingSpinner { + width: 3rem; + height: 3rem; + border: 0.4em solid #eee; + border-top-color: var(--ifm-color-primary); + border-radius: 50%; + animation: loading-spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes loading-spin { + 100% { + transform: rotate(360deg); + } +} + +.loader { + margin-top: 2rem; +} + +:global(.search-result-match) { + color: var(--docsearch-hit-color); + background: rgb(255 215 142 / 25%); + padding: 0.09em 0; +}