From d077fc72331879cca768a48a65fea584d8acd477 Mon Sep 17 00:00:00 2001 From: Enrique Gonzalez Date: Thu, 17 Sep 2026 22:44:29 +0000 Subject: [PATCH 1/5] Search revamp: product-keyed Algolia index, filter chips, breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the crawler-managed index with records built by dev/algolia-index.mjs from the contentlayer output, keyed by product from src/data/navigation.ts. Adds product filter chips and Page › Heading breadcrumbs to the search modal and a workflow that reindexes on push to main. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b0ef-f7ad-7413-9f23-e047e8da9b10 Co-authored-by: Amp --- .github/workflows/algolia-index.yml | 61 +++ .gitignore | 3 + dev/algolia-index.mjs | 508 ++++++++++++++++++ src/components/search/Search.tsx | 4 +- src/components/search/docsearch/DocSearch.tsx | 5 + .../search/docsearch/DocSearchModal.tsx | 55 +- src/components/search/docsearch/Results.tsx | 62 ++- src/components/search/docsearch/docsearch.css | 77 ++- .../search/docsearch/types/DocSearchHit.ts | 4 + src/data/search.ts | 46 +- 10 files changed, 796 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/algolia-index.yml create mode 100644 dev/algolia-index.mjs diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml new file mode 100644 index 000000000..06c406f8d --- /dev/null +++ b/.github/workflows/algolia-index.yml @@ -0,0 +1,61 @@ +name: Algolia index + +# Rebuilds the `sourcegraph_docs` Algolia index from the docs content whenever +# main changes. Records carry a `product` facet derived from +# src/data/navigation.ts, which the search modal uses for grouping, filter +# chips, and product boosts (see dev/algolia-index.mjs and src/data/search.ts). +# +# Needs the ALGOLIA_ADMIN_API_KEY repository secret: an Algolia API key for app +# 0EBA2NRQU3 with addObject, deleteObject, deleteIndex, settings, editSettings +# and browse ACLs, restricted to indices `sourcegraph_docs*`. + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/data/navigation.ts' + - 'contentlayer.config.ts' + - 'dev/algolia-index.mjs' + - '.github/workflows/algolia-index.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: algolia-index + cancel-in-progress: true + +jobs: + index: + name: Build and push index + runs-on: ubuntu-latest + steps: + - name: Check out main + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + # Node 24 is pre-cached on ubuntu-latest. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The indexer reads .contentlayer/generated, so build it first. + - name: Build contentlayer + run: pnpm exec contentlayer2 build + + - name: Build records (dry run, prints per-product counts) + run: node dev/algolia-index.mjs --dry-run --stats + + - name: Push index + env: + ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} + run: node dev/algolia-index.mjs diff --git a/.gitignore b/.gitignore index 4fb487043..a89a2fd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ next-env.d.ts # search index file generated on build /public/search.json +# Algolia records/settings generated by dev/algolia-index.mjs +/.algolia/ + # IDEs .idea diff --git a/dev/algolia-index.mjs b/dev/algolia-index.mjs new file mode 100644 index 000000000..d002492f3 --- /dev/null +++ b/dev/algolia-index.mjs @@ -0,0 +1,508 @@ +#!/usr/bin/env node +// Build the Algolia search index for the docs site from the contentlayer output. +// +// Records follow the DocSearch shape the search modal already understands +// (hierarchy.lvl0..lvl6, content, type, url, anchor), but `hierarchy.lvl0` is +// the product the page belongs to (Agentic Batch Changes, Deep Search, ...) +// instead of the sidebar separator the Algolia crawler used, and every record +// carries `product` / `section` facets plus DocSearch `weight` fields so the +// index's customRanking has something to rank on. +// +// Usage: +// node dev/algolia-index.mjs # build + push to `sourcegraph_docs` +// node dev/algolia-index.mjs --dry-run # build only; writes .algolia/records.ndjson + settings.json +// node dev/algolia-index.mjs --index NAME # push to another index +// node dev/algolia-index.mjs --stats # print product/type distribution (also on push) +// +// Requires `pnpm exec contentlayer2 build` (or a `next build`) to have populated +// `.contentlayer/generated`. Pushing uses `npx @algolia/cli`, which takes the +// credentials from ALGOLIA_APPLICATION_ID / ALGOLIA_ADMIN_API_KEY when set and +// otherwise from the profile stored by `algolia profile add` (local use). + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import GithubSlugger from 'github-slugger'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const generatedDir = path.join(root, '.contentlayer/generated/Post'); +const outDir = path.join(root, '.algolia'); + +const SITE_URL = 'https://sourcegraph.com/docs'; +const DEFAULT_INDEX = 'sourcegraph_docs'; +const DEFAULT_APP_ID = '0EBA2NRQU3'; +// Consecutive paragraphs under the same heading are merged into chunks of up to +// this many characters; snippets (`attributesToSnippet`) still surface the part +// that matched. +const MAX_CONTENT_LENGTH = 700; +const MIN_CONTENT_LENGTH = 3; +// Caps that keep generated reference pages (dashboards, alerts, changelog: +// thousands of headings each) from dominating the index. Headings are always +// indexed; only prose chunks are capped. +const MAX_CONTENT_PER_SECTION = 3; +const MAX_CONTENT_PER_PAGE = 600; + +const args = process.argv.slice(2); +const flag = name => args.includes(name); +const option = (name, fallback) => { + const i = args.indexOf(name); + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; +}; +const dryRun = flag('--dry-run'); +const indexName = option('--index', DEFAULT_INDEX); + +// --------------------------------------------------------------------------- +// Product mapping: page URL -> owning navigation topic +// --------------------------------------------------------------------------- + +// Pages that are reachable but not linked from the sidebar. +const FALLBACK_PRODUCTS = [ + {prefix: '/getting-started', product: 'Getting started', section: 'Documentation'}, + {prefix: '/how-to', product: 'How-to guides', section: 'Documentation'}, + {prefix: '/dotcom', product: 'Sourcegraph.com', section: 'Documentation'}, + {prefix: '/releases', product: 'Releases', section: 'Documentation'}, + {prefix: '/technical-changelog', product: 'Technical changelog', section: 'Documentation'}, + {prefix: '/legacy', product: 'Legacy', section: 'Documentation'}, + {prefix: '/pricing', product: 'Pricing', section: 'Resources'} +]; + +async function loadNavigationOwners() { + const {navigation} = await import( + path.join(root, 'src/data/navigation.ts') + ); + // href -> {product, section}. Registered in navigation order and first + // registration wins, so a page linked from two places (e.g. the Cody CLI + // page under both "Cody" and "Developer tools") belongs to the first. + const owners = new Map(); + const normalize = href => href.replace(/[#?].*$/, '').replace(/\/+$/, '') || '/'; + const register = (href, owner) => { + if (!href || href.startsWith('http')) return; + const key = normalize(href); + if (!owners.has(key)) owners.set(key, owner); + }; + const topics = navigation.flatMap(group => + group.topics.map(topic => ({...topic, section: group.separator})) + ); + // A single-page topic whose page lives under another topic's path (e.g. + // "Install Cody CLI" at /cody/clients/install-cli under "Cody" at /cody) is a + // shortcut, not a product of its own; the page belongs to the enclosing + // topic. Topics with their own sections (e.g. "Sourcegraph MCP server" at + // /api/mcp under /api) stay separate products. + const productOf = topic => { + const href = normalize(topic.href ?? ''); + const parent = + !topic.sections?.length && + topics.find( + other => + other !== topic && + other.href && + normalize(other.href) !== href && + isPathPrefix(normalize(other.href), href) + ); + return parent ? productOf(parent) : {product: topic.title, section: topic.section}; + }; + for (const topic of topics) { + { + const owner = productOf(topic); + register(topic.href, owner); + for (const sec of topic.sections ?? []) { + register(sec.href, owner); + for (const sub of sec.subsections ?? []) register(sub.href, owner); + } + } + } + return owners; +} + +function isPathPrefix(prefix, url) { + return url === prefix || url.startsWith(prefix + '/'); +} + +function resolveOwner(url, owners) { + let best; + for (const [href, owner] of owners) { + if (isPathPrefix(href, url) && (!best || href.length > best.href.length)) { + best = {href, owner}; + } + } + if (best) return best.owner; + const fallback = FALLBACK_PRODUCTS.find(f => isPathPrefix(f.prefix, url)); + if (fallback) return {product: fallback.product, section: fallback.section}; + return {product: 'Sourcegraph Docs', section: 'Documentation'}; +} + +// --------------------------------------------------------------------------- +// Markdown/MDX -> plain text blocks +// --------------------------------------------------------------------------- + +const regXFenceLine = /^\s*(`{3,}|~{3,})/; + +// Same walk as contentlayer.config.ts: drop fenced code blocks so `# comment` +// lines inside them are not taken for headings and code is not indexed as prose. +function stripFencedCodeBlocks(markdown) { + const kept = []; + let openFence; + for (const line of markdown.split('\n')) { + const fence = line.match(regXFenceLine)?.[1]; + if (openFence) { + const closes = + fence !== undefined && + fence[0] === openFence[0] && + fence.length >= openFence.length && + line.trim() === fence; + if (closes) openFence = undefined; + } else if (fence) { + openFence = fence; + } else { + kept.push(line); + } + } + return kept.join('\n'); +} + +function stripMdxNoise(markdown) { + // Inline code spans keep their contents verbatim: `` in a + // heading is text, not a tag. + const codeSpans = []; + const protect = markdown.replace(/`[^`\n]*`/g, span => { + codeSpans.push(span); + return `\u0000${codeSpans.length - 1}\u0000`; + }); + return protect + .replace(//g, '') + .replace(/\{\/\*[\s\S]*?\*\/\}/g, '') + .replace(/^\s*(import|export)\b[^\n]*(\n\s+[^\n]*)*/gm, '') + // Opening/closing/self-closing JSX or HTML tags, possibly spanning lines. + .replace(/<\/?[A-Za-z][\w.-]*(?:\s[^<>]*?)?\/?>/g, '\n') + .replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)]); +} + +// Inline cleanup for a single text block. +function cleanInline(text) { + return text + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') // reference links + .replace(/`([^`]*)`/g, '$1') // inline code + .replace(/\{\s*(['"`])\s*\1\s*\}/g, ' ') // {' '} JSX spacers + .replace(/\{#[\w-]+\}\s*$/, '') // {#custom-heading-id} + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(^|\s)[*_](\S.*?\S|\S)[*_](?=[\s.,;:!?)]|$)/g, '$1$2') + .replace(/~~(.*?)~~/g, '$1') + .replace(/\\([\\`*_{}[\]()#+\-.!|])/g, '$1') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\s+/g, ' ') + .trim(); +} + +function headingTitle(rawContent) { + // Same as contentlayer.config.ts: '## [Text](/link)' -> 'Text'. + const match = rawContent.match(/\[([^\]]+)\]\([^)]+\)/); + return match ? match[1] : rawContent; +} + +const regXHeading = /^ *(#{1,6})\s+(.+)/; +const regXListItem = /^\s*(?:[-*+]|\d+[.)])\s+/; +const regXTableSeparator = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/; + +// Yields {kind: 'heading', level, title} and {kind: 'text', text} in document order. +function* blocks(markdown) { + const lines = stripMdxNoise(stripFencedCodeBlocks(markdown)).split('\n'); + let paragraph = []; + const flush = function* () { + if (paragraph.length) { + const text = cleanInline(paragraph.join(' ')); + paragraph = []; + if (text) yield {kind: 'text', text}; + } + }; + for (const rawLine of lines) { + const line = rawLine.replace(/^\s*>\s?/, ''); // blockquotes + const heading = line.match(regXHeading); + if (heading) { + yield* flush(); + const title = cleanInline(headingTitle(heading[2])); + if (title) yield {kind: 'heading', level: heading[1].length, title}; + continue; + } + if (!line.trim() || /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + yield* flush(); + continue; + } + if (regXListItem.test(line)) { + yield* flush(); + paragraph.push(line.replace(regXListItem, '').replace(/^\[[ xX]\]\s+/, '')); + continue; + } + if (/^\s*\|/.test(line)) { + yield* flush(); + if (regXTableSeparator.test(line)) continue; + const cells = line + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map(c => cleanInline(c)) + .filter(Boolean); + if (cells.length) yield {kind: 'text', text: cells.join(' — ')}; + continue; + } + paragraph.push(line); + } + yield* flush(); +} + +// --------------------------------------------------------------------------- +// Records +// --------------------------------------------------------------------------- + +const LEVEL_WEIGHT = {lvl1: 100, lvl2: 90, lvl3: 80, lvl4: 70, lvl5: 60, lvl6: 50, content: 0}; + +function pageTitleFromPath(url) { + const last = url.split('/').filter(Boolean).pop() ?? 'Sourcegraph Docs'; + return last.replace(/[-_]+/g, ' ').replace(/^\w/, c => c.toUpperCase()); +} + +function buildPageRecords(post, owner) { + const url = post.url; + const pageUrl = SITE_URL + url; + const slugger = new GithubSlugger(); + const pageRank = Math.round((post.seoPriority ?? 0.5) * 100); + const records = []; + let position = 0; + + const hierarchy = { + lvl0: owner.product, + lvl1: post.title ?? null, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null + }; + let anchor = null; + + const push = (type, content) => { + const rec = { + objectID: `${url}#${position}`, + type, + content, + anchor, + url: anchor ? `${pageUrl}#${anchor}` : pageUrl, + url_without_anchor: pageUrl, + hierarchy: {...hierarchy}, + product: owner.product, + section: owner.section, + weight: {pageRank, level: LEVEL_WEIGHT[type], position} + }; + records.push(rec); + position += 1; + }; + + // Prose under the current heading, merged into chunks and flushed when the + // next heading starts. + let pending = []; + let contentInSection = 0; + let contentInPage = 0; + const flushContent = () => { + let chunk = ''; + const chunks = []; + for (const text of pending) { + if (chunk && chunk.length + 1 + text.length > MAX_CONTENT_LENGTH) { + chunks.push(chunk); + chunk = ''; + } + chunk = chunk ? `${chunk} ${text}` : text; + } + if (chunk) chunks.push(chunk); + pending = []; + for (const c of chunks) { + if (contentInSection >= MAX_CONTENT_PER_SECTION || contentInPage >= MAX_CONTENT_PER_PAGE) break; + push('content', c.slice(0, MAX_CONTENT_LENGTH)); + contentInSection += 1; + contentInPage += 1; + } + contentInSection = 0; + }; + + let sawH1 = false; + const ensurePageRecord = () => { + if (sawH1) return; + sawH1 = true; + hierarchy.lvl1 ??= pageTitleFromPath(url); + push('lvl1', null); + }; + + for (const block of blocks(post.body.raw)) { + if (block.kind === 'heading') { + flushContent(); + const id = slugger.slug(block.title); + if (block.level === 1 && !sawH1) { + // The first H1 is the page title and the page record itself. + sawH1 = true; + hierarchy.lvl1 = block.title; + anchor = null; + push('lvl1', null); + continue; + } + ensurePageRecord(); + // A second H1 is treated like an H2 so it stays under the page. + const level = Math.min(Math.max(block.level, 2), 6); + hierarchy[`lvl${level}`] = block.title; + for (let l = level + 1; l <= 6; l++) hierarchy[`lvl${l}`] = null; + anchor = id; + push(`lvl${level}`, null); + continue; + } + if (block.text === 'On this page') continue; + if (block.text.length < MIN_CONTENT_LENGTH) continue; + ensurePageRecord(); + pending.push(block.text); + } + flushContent(); + ensurePageRecord(); + return records; +} + +function loadPosts() { + if (!fs.existsSync(generatedDir)) { + throw new Error( + `${path.relative(root, generatedDir)} not found; run \`pnpm exec contentlayer2 build\` first` + ); + } + return fs + .readdirSync(generatedDir) + .filter(f => f.endsWith('.json') && !f.startsWith('_')) + .map(f => JSON.parse(fs.readFileSync(path.join(generatedDir, f), 'utf8'))) + .sort((a, b) => a.url.localeCompare(b.url)); +} + +// Standard DocSearch index settings plus our product/section facets. +const settings = { + searchableAttributes: [ + 'unordered(hierarchy.lvl0)', + 'unordered(hierarchy.lvl1)', + 'unordered(hierarchy.lvl2)', + 'unordered(hierarchy.lvl3)', + 'unordered(hierarchy.lvl4)', + 'unordered(hierarchy.lvl5)', + 'unordered(hierarchy.lvl6)', + 'content' + ], + attributesToRetrieve: [ + 'hierarchy', + 'content', + 'anchor', + 'url', + 'url_without_anchor', + 'type', + 'product', + 'section' + ], + attributesToHighlight: ['hierarchy', 'content'], + attributesToSnippet: ['content:10'], + attributesForFaceting: ['product', 'section', 'type'], + // At most 3 hits per page, so a query that matches a whole product (e.g. + // "agentic batch changes" matches every record's lvl0) lists the product's + // pages instead of the table of contents of its landing page. + attributeForDistinct: 'url_without_anchor', + distinct: 3, + // Page titles before headings before prose; pageRank (from seoPriority) + // only breaks ties within a level, otherwise a landing page's headings + // would outrank its sibling pages. + customRanking: [ + 'desc(weight.level)', + 'desc(weight.pageRank)', + 'asc(weight.position)' + ], + ranking: ['words', 'filters', 'typo', 'attribute', 'proximity', 'exact', 'custom'], + highlightPreTag: '', + highlightPostTag: '', + minWordSizefor1Typo: 3, + minWordSizefor2Typos: 7, + allowTyposOnNumericTokens: false, + minProximity: 1, + ignorePlurals: true, + advancedSyntax: true, + attributeCriteriaComputedByMinProximity: true, + removeWordsIfNoResults: 'allOptional', + hitsPerPage: 20 +}; + +// --------------------------------------------------------------------------- +// Push via the Algolia CLI +// --------------------------------------------------------------------------- + +function algolia(cliArgs) { + const creds = []; + if (process.env.ALGOLIA_ADMIN_API_KEY) { + creds.push('--application-id', process.env.ALGOLIA_APPLICATION_ID ?? DEFAULT_APP_ID); + creds.push('--api-key', process.env.ALGOLIA_ADMIN_API_KEY); + } + const full = ['-y', '@algolia/cli', ...cliArgs, ...creds]; + console.log(`$ npx ${cliArgs.join(' ')}`); + const res = spawnSync('npx', full, {stdio: 'inherit', cwd: root}); + if (res.status !== 0) { + throw new Error(`algolia ${cliArgs[0]} ${cliArgs[1]} failed with exit code ${res.status}`); + } +} + +function printStats(records) { + const count = (key, fn) => { + const m = new Map(); + for (const r of records) { + const k = fn(r); + m.set(k, (m.get(k) ?? 0) + 1); + } + console.log(`\n${key}:`); + for (const [k, v] of [...m].sort((a, b) => b[1] - a[1])) { + console.log(` ${String(v).padStart(5)} ${k}`); + } + }; + count('records per product', r => `${r.product} (${r.section})`); + count('records per type', r => r.type); +} + +async function main() { + const owners = await loadNavigationOwners(); + const posts = loadPosts().filter(p => !p.preview); + const records = []; + const pages = []; + for (const post of posts) { + const owner = resolveOwner(post.url, owners); + pages.push({url: post.url, ...owner}); + records.push(...buildPageRecords(post, owner)); + } + + fs.mkdirSync(outDir, {recursive: true}); + const recordsFile = path.join(outDir, 'records.ndjson'); + const settingsFile = path.join(outDir, 'settings.json'); + fs.writeFileSync(recordsFile, records.map(r => JSON.stringify(r)).join('\n') + '\n'); + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); + fs.writeFileSync( + path.join(outDir, 'pages.json'), + JSON.stringify(pages, null, 2) + '\n' + ); + console.log( + `Built ${records.length} records for ${pages.length} pages -> ${path.relative(root, recordsFile)}` + ); + if (flag('--stats') || !dryRun) printStats(records); + + if (dryRun) return; + + // Build into a temporary index and move it over the live one so searches + // never see a half-populated index. + const tmpIndex = `${indexName}_tmp`; + algolia(['settings', 'import', tmpIndex, '-F', settingsFile, '--wait']); + algolia(['objects', 'import', tmpIndex, '-F', recordsFile, '--wait']); + algolia(['indices', 'move', tmpIndex, indexName, '--confirm', '--wait']); + console.log(`\nIndex "${indexName}" updated with ${records.length} records.`); +} + +main().catch(err => { + console.error(err.message ?? err); + process.exit(1); +}); diff --git a/src/components/search/Search.tsx b/src/components/search/Search.tsx index 545c07a62..687cfdaef 100644 --- a/src/components/search/Search.tsx +++ b/src/components/search/Search.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import {searchMetadata} from '../../data/search'; +import {productFilters, searchMetadata} from '../../data/search'; import {DocSearch} from './docsearch/DocSearch'; import type {DocSearchHit} from './docsearch/types'; import './docsearch/docsearch.css'; @@ -48,6 +48,8 @@ export const Search = () => { apiKey={algoliaConfig.apiKey} initialQuery={initialQuery} maxResultsPerGroup={algoliaConfig.maxResultsPerGroup} + searchParameters={algoliaConfig.searchParameters} + productFilters={productFilters} transformItems={transformItems} /> ); diff --git a/src/components/search/docsearch/DocSearch.tsx b/src/components/search/docsearch/DocSearch.tsx index 6b0d2d1c5..3c4e50096 100644 --- a/src/components/search/docsearch/DocSearch.tsx +++ b/src/components/search/docsearch/DocSearch.tsx @@ -31,6 +31,11 @@ export interface DocSearchProps { indexName: string; placeholder?: string; searchParameters?: SearchOptions; + /** + * Product names (values of the `product` facet) offered as filter chips + * above the results. Empty or undefined hides the chip row. + */ + productFilters?: string[]; maxResultsPerGroup?: number; transformItems?: (items: DocSearchHit[]) => DocSearchHit[]; hitComponent?: (props: { diff --git a/src/components/search/docsearch/DocSearchModal.tsx b/src/components/search/docsearch/DocSearchModal.tsx index 738b1743a..dc92d871c 100644 --- a/src/components/search/docsearch/DocSearchModal.tsx +++ b/src/components/search/docsearch/DocSearchModal.tsx @@ -50,6 +50,7 @@ export function DocSearchModal({ indexName, placeholder = 'What are you searching for?', searchParameters, + productFilters = [], maxResultsPerGroup, onClose = noop, transformItems = identity, @@ -87,6 +88,13 @@ export function DocSearchModal({ const dropdownRef = React.useRef(null); const inputRef = React.useRef(null); const snippetLength = React.useRef(10); + // The selected product chip. Kept in a ref as well as state so that + // `getSources` (captured once by `createAutocomplete`) always reads the + // current value without recreating the autocomplete instance. + const [activeProduct, setActiveProduct] = React.useState( + null + ); + const activeProductRef = React.useRef(null); const initialQueryFromSelection = React.useRef( typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) @@ -228,6 +236,7 @@ export function DocSearchModal({ } const insightsActive = Boolean(insights); + const product = activeProductRef.current; return searchClient .search([ @@ -245,8 +254,12 @@ export function DocSearchModal({ 'hierarchy.lvl6', 'content', 'type', - 'url' + 'url', + 'product' ], + ...(product + ? {facetFilters: [`product:${product}`]} + : {}), attributesToSnippet: [ `hierarchy.lvl1:${snippetLength.current}`, `hierarchy.lvl2:${snippetLength.current}`, @@ -404,6 +417,18 @@ export function DocSearchModal({ const {getEnvironmentProps, getRootProps, refresh} = autocomplete; + const selectProduct = React.useCallback( + (product: string | null) => { + const next = product === activeProductRef.current ? null : product; + activeProductRef.current = next; + setActiveProduct(next); + // Re-run `getSources` with the current query and the new filter. + refresh(); + inputRef.current?.focus(); + }, + [refresh] + ); + useTouchEvents({ getEnvironmentProps, panelElement: dropdownRef.current, @@ -525,6 +550,34 @@ export function DocSearchModal({ /> + {productFilters.length > 0 && ( +
+ + {productFilters.map(product => ( + + ))} +
+ )} +
hit.hierarchy[level]); + if (deepest) path.push(deepest); + return path; + } + for (const level of HEADING_LEVELS) { + if (level === hit.type) break; + if (hit.hierarchy[level]) path.push(level); + } + return path; +} + +function HitPath({hit}: {hit: StoredDocSearchHit}) { + const levels = getPathLevels(hit); + return ( + + {levels.map((level, index) => ( + + {index > 0 && ( + + {' › '} + + )} + + + ))} + + ); +} + interface ResultsProps extends AutocompleteApi< TItem, @@ -136,11 +186,7 @@ function Result({ hit={item} attribute={`hierarchy.${item.type}`} /> - +
)} @@ -151,11 +197,7 @@ function Result({ hit={item} attribute="content" /> - + )} diff --git a/src/components/search/docsearch/docsearch.css b/src/components/search/docsearch/docsearch.css index 1c6365e80..36b576f6e 100644 --- a/src/components/search/docsearch/docsearch.css +++ b/src/components/search/docsearch/docsearch.css @@ -157,11 +157,16 @@ html.dark { background: var(--docsearch-modal-background); border-radius: 6px; box-shadow: var(--docsearch-modal-shadow); + display: flex; flex-direction: column; margin: 60px auto auto; + max-height: var(--docsearch-modal-height); max-width: var(--docsearch-modal-width); position: relative; } +.DocSearch-Modal > header { + flex-shrink: 0; +} .DocSearch-SearchBar { display: flex; padding: var(--docsearch-spacing) var(--docsearch-spacing) 0; @@ -263,11 +268,53 @@ html.dark { .DocSearch-Cancel { display: none; } +.DocSearch-Products { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 6px; + padding: 8px var(--docsearch-spacing); + border-bottom: 1px solid theme('colors.light-border-2'); +} +html.dark .DocSearch-Products, +html[data-theme='dark'] .DocSearch-Products { + border-bottom-color: theme('colors.dark-border'); +} +.DocSearch-Product { + flex: 0 0 auto; + appearance: none; + border: 1px solid transparent; + border-radius: 9999px; + background: var(--docsearch-searchbox-background); + color: var(--docsearch-muted-color); + font-size: 0.75em; + font-weight: 500; + line-height: 1; + padding: 6px 10px; + white-space: nowrap; + cursor: pointer; +} +.DocSearch-Product:hover { + color: var(--docsearch-highlight-color); + border-color: var(--docsearch-highlight-color); +} +.DocSearch-Product:focus-visible { + outline: 2px solid var(--docsearch-primary-color); + outline-offset: 1px; +} +.DocSearch-Product[aria-pressed='true'] { + background: var(--docsearch-primary-color); + border-color: var(--docsearch-primary-color); + color: theme('colors.white'); +} +.DocSearch-Hit-path-separator { + opacity: 0.6; +} + .DocSearch-Dropdown { - max-height: calc( - var(--docsearch-modal-height) - var(--docsearch-searchbox-height) - - var(--docsearch-spacing) - var(--docsearch-footer-height) - ); + /* Takes whatever height the header, product chips, and footer leave over + inside the modal's max-height. */ + flex: 1 1 auto; min-height: var(--docsearch-spacing); overflow-y: auto; overflow-y: overlay; @@ -589,9 +636,6 @@ svg.DocSearch-Hit-Select-Icon { --docsearch-spacing: 10px; --docsearch-footer-height: 40px; } - .DocSearch-Dropdown { - height: 100%; - } .DocSearch-Container { height: 100vh; height: -webkit-fill-available; @@ -600,8 +644,16 @@ svg.DocSearch-Hit-Select-Icon { } .DocSearch-Footer { border-radius: 0; - bottom: 0; - position: absolute; + } + /* One swipeable row of chips instead of several wrapped rows. */ + .DocSearch-Products { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + } + .DocSearch-Products::-webkit-scrollbar { + display: none; } .DocSearch-Hit-content-wrapper { display: flex; @@ -615,15 +667,10 @@ svg.DocSearch-Hit-Select-Icon { height: -webkit-fill-available; height: calc(var(--docsearch-vh, 1vh) * 100); margin: 0; + max-height: none; max-width: 100%; width: 100%; } - .DocSearch-Dropdown { - max-height: calc( - var(--docsearch-vh, 1vh) * 100 - var(--docsearch-searchbox-height) - - var(--docsearch-spacing) - var(--docsearch-footer-height) - ); - } .DocSearch-Cancel { appearance: none; background: none; diff --git a/src/components/search/docsearch/types/DocSearchHit.ts b/src/components/search/docsearch/types/DocSearchHit.ts index 3f6001e58..fa03b52ca 100644 --- a/src/components/search/docsearch/types/DocSearchHit.ts +++ b/src/components/search/docsearch/types/DocSearchHit.ts @@ -49,6 +49,10 @@ export declare type DocSearchHit = { url_without_anchor: string; type: ContentType; anchor: string | null; + /** Product facet (nav topic title), e.g. "Agentic Batch Changes". */ + product?: string; + /** Nav section the product belongs to, e.g. "Code Intelligence". */ + section?: string; hierarchy: { lvl0: string; lvl1: string; diff --git a/src/data/search.ts b/src/data/search.ts index 51dea30e4..4c3af0bd9 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -1,3 +1,41 @@ +/** + * Products that get a ranking boost at query time. Records in the + * `sourcegraph_docs` index carry a `product` facet (see dev/algolia-index.mjs); + * each entry here becomes an Algolia `optionalFilters` clause, so hits from + * that product outrank otherwise-equal hits without hiding anything else. + * + * Score is an integer; higher wins. A boost of 3 is enough to put "Agentic + * Batch Changes" above "Batch Changes" for the query "batch changes". + */ +export const productBoosts: Record = { + 'Agentic Batch Changes': 3, + 'Deep Search': 2, + 'Code Search': 1 +}; + +/** + * Products offered as filter chips in the search modal, in display order. + * Names must match the `product` facet values in the index (i.e. the topic + * titles in src/data/navigation.ts). + */ +export const productFilters: string[] = [ + 'Agentic Batch Changes', + 'Deep Search', + 'Code Search', + 'Code Navigation', + 'Batch Changes', + 'Code Insights', + 'Cody', + 'MCP Server', + 'Sourcegraph CLI', + 'Administration', + 'Self-hosted' +]; + +const optionalFilters = Object.entries(productBoosts).map( + ([product, score]) => `product:${product}` +); + export const searchMetadata = { provider: 'kbar', kbarConfig: { @@ -10,7 +48,11 @@ export const searchMetadata = { appId: '0EBA2NRQU3', // Public API key: it is safe to commit it apiKey: '1b6e51c1d4ef24bef0a5f1ab00dad80a', - indexName: 'sourcegraph', - maxResultsPerGroup: 20 + // Built by dev/algolia-index.mjs (not the Algolia crawler). + indexName: 'sourcegraph_docs', + maxResultsPerGroup: 20, + searchParameters: { + optionalFilters + } } }; From 41096f12d0964c7bc763052735c84577ead528c4 Mon Sep 17 00:00:00 2001 From: Enrique Gonzalez Date: Thu, 17 Sep 2026 22:44:46 +0000 Subject: [PATCH 2/5] Fix Algolia ranking and index hygiene Drops the product optionalFilters boosts, which sat ahead of typo/attribute/ exact in Algolia's ranking and pushed Agentic Batch Changes and Deep Search hits above exact matches (batch changes, saml, SSO, getting started). Removes hierarchy.lvl0 (product) from searchable attributes so a product name in the query no longer matches every record in that product; adds English stop words and plurals so question-style queries work; imports synonyms (API key/access token, SSO/SAML/single sign-on, ...) into the temp index before the atomic move; dedupes repeated boilerplate content blocks by normalized hash; strips frontmatter; sets distinct to 2. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b0ef-f7ad-7413-9f23-e047e8da9b10 Co-authored-by: Amp --- .github/workflows/algolia-index.yml | 2 +- dev/algolia-index.mjs | 74 +++++++++++++++++++++++++---- src/data/search.ts | 23 +-------- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index 06c406f8d..d3f3402cf 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -3,7 +3,7 @@ name: Algolia index # Rebuilds the `sourcegraph_docs` Algolia index from the docs content whenever # main changes. Records carry a `product` facet derived from # src/data/navigation.ts, which the search modal uses for grouping, filter -# chips, and product boosts (see dev/algolia-index.mjs and src/data/search.ts). +# chips (see dev/algolia-index.mjs and src/data/search.ts). # # Needs the ALGOLIA_ADMIN_API_KEY repository secret: an Algolia API key for app # 0EBA2NRQU3 with addObject, deleteObject, deleteIndex, settings, editSettings diff --git a/dev/algolia-index.mjs b/dev/algolia-index.mjs index d002492f3..825001b58 100644 --- a/dev/algolia-index.mjs +++ b/dev/algolia-index.mjs @@ -20,6 +20,7 @@ // otherwise from the profile stored by `algolia profile add` (local use). import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -37,6 +38,10 @@ const DEFAULT_APP_ID = '0EBA2NRQU3'; // that matched. const MAX_CONTENT_LENGTH = 700; const MIN_CONTENT_LENGTH = 3; +// Only deduplicate substantial prose. Short labels such as "Permissions" can +// be useful on every page where they occur; long identical chunks are usually +// generated-reference boilerplate. +const MIN_DEDUPLICATION_LENGTH = 80; // Caps that keep generated reference pages (dashboards, alerts, changelog: // thousands of headings each) from dominating the index. Headings are always // indexed; only prose chunks are capped. @@ -162,6 +167,9 @@ function stripFencedCodeBlocks(markdown) { } function stripMdxNoise(markdown) { + // Contentlayer's body.raw excludes frontmatter. Strip it explicitly too so + // this remains true if the indexer's input changes in the future. + markdown = markdown.replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, ''); // Inline code spans keep their contents verbatim: `` in a // heading is text, not a tag. const codeSpans = []; @@ -266,7 +274,13 @@ function pageTitleFromPath(url) { return last.replace(/[-_]+/g, ' ').replace(/^\w/, c => c.toUpperCase()); } -function buildPageRecords(post, owner) { +function contentHash(content) { + return createHash('sha256') + .update(content.toLowerCase().replace(/\s+/g, ' ').trim()) + .digest('hex'); +} + +function buildPageRecords(post, owner, deduplication) { const url = post.url; const pageUrl = SITE_URL + url; const slugger = new GithubSlugger(); @@ -360,6 +374,14 @@ function buildPageRecords(post, owner) { if (block.text === 'On this page') continue; if (block.text.length < MIN_CONTENT_LENGTH) continue; ensurePageRecord(); + if (block.text.length >= MIN_DEDUPLICATION_LENGTH) { + const hash = contentHash(block.text); + if (deduplication.hashes.has(hash)) { + deduplication.removed += 1; + continue; + } + deduplication.hashes.add(hash); + } pending.push(block.text); } flushContent(); @@ -380,10 +402,27 @@ function loadPosts() { .sort((a, b) => a.url.localeCompare(b.url)); } +function deduplicateContent(records) { + const seen = new Set(); + let removed = 0; + const unique = records.filter(record => { + if (record.type !== 'content' || record.content.length < MIN_DEDUPLICATION_LENGTH) { + return true; + } + const hash = contentHash(record.content); + if (seen.has(hash)) { + removed += 1; + return false; + } + seen.add(hash); + return true; + }); + return {records: unique, removed}; +} + // Standard DocSearch index settings plus our product/section facets. const settings = { searchableAttributes: [ - 'unordered(hierarchy.lvl0)', 'unordered(hierarchy.lvl1)', 'unordered(hierarchy.lvl2)', 'unordered(hierarchy.lvl3)', @@ -405,11 +444,9 @@ const settings = { attributesToHighlight: ['hierarchy', 'content'], attributesToSnippet: ['content:10'], attributesForFaceting: ['product', 'section', 'type'], - // At most 3 hits per page, so a query that matches a whole product (e.g. - // "agentic batch changes" matches every record's lvl0) lists the product's - // pages instead of the table of contents of its landing page. + // Keep the page record plus at most one matching heading or prose chunk. attributeForDistinct: 'url_without_anchor', - distinct: 3, + distinct: 2, // Page titles before headings before prose; pageRank (from seoPriority) // only breaks ties within a level, otherwise a landing page's headings // would outrank its sibling pages. @@ -425,13 +462,24 @@ const settings = { minWordSizefor2Typos: 7, allowTyposOnNumericTokens: false, minProximity: 1, - ignorePlurals: true, + removeStopWords: ['en'], + ignorePlurals: ['en'], advancedSyntax: true, attributeCriteriaComputedByMinProximity: true, removeWordsIfNoResults: 'allOptional', hitsPerPage: 20 }; +const synonyms = [ + {objectID: 'api-key-access-token', type: 'synonym', synonyms: ['API key', 'access token']}, + {objectID: 'sso-saml', type: 'synonym', synonyms: ['SSO', 'SAML', 'single sign-on']}, + {objectID: 'login-sign-in', type: 'synonym', synonyms: ['login', 'sign in']}, + {objectID: 'repo-repository', type: 'synonym', synonyms: ['repo', 'repository']}, + {objectID: 'auth-authentication', type: 'synonym', synonyms: ['auth', 'authentication']}, + {objectID: 'perms-permissions', type: 'synonym', synonyms: ['perms', 'permissions']}, + {objectID: 'src-cli', type: 'synonym', synonyms: ['src-cli', 'src CLI']} +]; + // --------------------------------------------------------------------------- // Push via the Algolia CLI // --------------------------------------------------------------------------- @@ -469,25 +517,30 @@ function printStats(records) { async function main() { const owners = await loadNavigationOwners(); const posts = loadPosts().filter(p => !p.preview); - const records = []; + const allRecords = []; const pages = []; + const deduplication = {hashes: new Set(), removed: 0}; for (const post of posts) { const owner = resolveOwner(post.url, owners); pages.push({url: post.url, ...owner}); - records.push(...buildPageRecords(post, owner)); + allRecords.push(...buildPageRecords(post, owner, deduplication)); } + const {records, removed: duplicateRecords} = deduplicateContent(allRecords); + const removed = deduplication.removed + duplicateRecords; fs.mkdirSync(outDir, {recursive: true}); const recordsFile = path.join(outDir, 'records.ndjson'); const settingsFile = path.join(outDir, 'settings.json'); + const synonymsFile = path.join(outDir, 'synonyms.ndjson'); fs.writeFileSync(recordsFile, records.map(r => JSON.stringify(r)).join('\n') + '\n'); fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); + fs.writeFileSync(synonymsFile, synonyms.map(s => JSON.stringify(s)).join('\n') + '\n'); fs.writeFileSync( path.join(outDir, 'pages.json'), JSON.stringify(pages, null, 2) + '\n' ); console.log( - `Built ${records.length} records for ${pages.length} pages -> ${path.relative(root, recordsFile)}` + `Built ${records.length} records for ${pages.length} pages (${removed} duplicate content blocks removed) -> ${path.relative(root, recordsFile)}` ); if (flag('--stats') || !dryRun) printStats(records); @@ -498,6 +551,7 @@ async function main() { const tmpIndex = `${indexName}_tmp`; algolia(['settings', 'import', tmpIndex, '-F', settingsFile, '--wait']); algolia(['objects', 'import', tmpIndex, '-F', recordsFile, '--wait']); + algolia(['synonyms', 'import', tmpIndex, '-F', synonymsFile, '--replace-existing-synonyms', '--wait']); algolia(['indices', 'move', tmpIndex, indexName, '--confirm', '--wait']); console.log(`\nIndex "${indexName}" updated with ${records.length} records.`); } diff --git a/src/data/search.ts b/src/data/search.ts index 4c3af0bd9..e1edb75ec 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -1,18 +1,3 @@ -/** - * Products that get a ranking boost at query time. Records in the - * `sourcegraph_docs` index carry a `product` facet (see dev/algolia-index.mjs); - * each entry here becomes an Algolia `optionalFilters` clause, so hits from - * that product outrank otherwise-equal hits without hiding anything else. - * - * Score is an integer; higher wins. A boost of 3 is enough to put "Agentic - * Batch Changes" above "Batch Changes" for the query "batch changes". - */ -export const productBoosts: Record = { - 'Agentic Batch Changes': 3, - 'Deep Search': 2, - 'Code Search': 1 -}; - /** * Products offered as filter chips in the search modal, in display order. * Names must match the `product` facet values in the index (i.e. the topic @@ -32,10 +17,6 @@ export const productFilters: string[] = [ 'Self-hosted' ]; -const optionalFilters = Object.entries(productBoosts).map( - ([product, score]) => `product:${product}` -); - export const searchMetadata = { provider: 'kbar', kbarConfig: { @@ -51,8 +32,6 @@ export const searchMetadata = { // Built by dev/algolia-index.mjs (not the Algolia crawler). indexName: 'sourcegraph_docs', maxResultsPerGroup: 20, - searchParameters: { - optionalFilters - } + searchParameters: {} } }; From e75fcce4b38227cfc65e5d085cea4d587169dc8b Mon Sep 17 00:00:00 2001 From: Enrique Gonzalez Date: Thu, 17 Sep 2026 22:45:12 +0000 Subject: [PATCH 3/5] Improve search modal density and navigation One scrollable product chip row with an overflow affordance; page/heading/ content icons per hit; match highlighting in breadcrumbs; one-line windowed snippets and none on page or heading hits; page and heading hits sorted before content hits with duplicate snippets removed per group; curated empty-state suggestions; linked products on the no-results screen; route prefetch for the highlighted hit; Ctrl/Cmd+Enter opens in a new tab; previous results stay visible while loading; platform-aware shortcut hint; the query is mirrored to ?search= so a search can be shared; full-height modal at 390px. Also clears the React 19 hooks lint warnings in the vendored DocSearch files. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b0ef-f7ad-7413-9f23-e047e8da9b10 Co-authored-by: Amp --- src/components/search/Search.tsx | 45 +++- .../search/docsearch/DocSearchButton.tsx | 21 +- .../search/docsearch/DocSearchModal.tsx | 181 +++++++++------ .../search/docsearch/NoResultsScreen.tsx | 47 ++-- src/components/search/docsearch/Results.tsx | 29 +-- src/components/search/docsearch/SearchBox.tsx | 66 ++++-- .../search/docsearch/StartScreen.tsx | 44 +++- src/components/search/docsearch/docsearch.css | 219 +++++++++++++----- .../search/docsearch/icons/SourceIcon.tsx | 13 +- .../search/docsearch/useTouchEvents.ts | 17 +- .../search/docsearch/useTrapFocus.ts | 7 +- src/data/search.ts | 47 ++++ 12 files changed, 485 insertions(+), 251 deletions(-) diff --git a/src/components/search/Search.tsx b/src/components/search/Search.tsx index 687cfdaef..e1f5547b8 100644 --- a/src/components/search/Search.tsx +++ b/src/components/search/Search.tsx @@ -1,4 +1,3 @@ -import {useEffect, useState} from 'react'; import {productFilters, searchMetadata} from '../../data/search'; import {DocSearch} from './docsearch/DocSearch'; import type {DocSearchHit} from './docsearch/types'; @@ -18,27 +17,51 @@ const toLocalUrl = (url: string): string => ? basePath + url.slice(PROD_DOCS_URL_PREFIX.length) : url; -const transformItems = (items: DocSearchHit[]): DocSearchHit[] => - items.map(item => ({...item, url: toLocalUrl(item.url)})); +const hitPriority = (hit: DocSearchHit): number => { + if (hit.type === 'lvl1') return 0; + if (hit.type === 'content') return 2; + return 1; +}; + +const normalizeSnippet = (hit: DocSearchHit): string => + (hit._snippetResult.content?.value || hit.content || '') + .replace(/<\/?mark>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + +const transformItems = (items: DocSearchHit[]): DocSearchHit[] => { + const snippets = new Set(); + + return items + .map((item, index) => ({item, index})) + .sort( + (a, b) => + hitPriority(a.item) - hitPriority(b.item) || a.index - b.index + ) + .filter(({item}) => { + if (item.type !== 'content') return true; + const snippet = normalizeSnippet(item); + if (!snippet) return true; + if (snippets.has(snippet)) return false; + snippets.add(snippet); + return true; + }) + .map(({item}) => ({...item, url: toLocalUrl(item.url)})); +}; const getInitialQuery = () => { if (typeof window !== 'undefined' && window?.location?.href) { const url = new URL(window.location.href); + const sharedQuery = url.searchParams.get('search'); const hashQuery = url.hash?.slice(1); const params = new URLSearchParams(hashQuery); - const query = params.get('q'); + const query = sharedQuery || params.get('q'); return query ?? undefined; } }; export const Search = () => { - let [modifierKey, setModifierKey] = useState(); - - useEffect(() => { - const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); - setModifierKey(isMac ? '⌘' : 'Ctrl'); - }, []); - const initialQuery = getInitialQuery(); const {algoliaConfig} = searchMetadata; return ( diff --git a/src/components/search/docsearch/DocSearchButton.tsx b/src/components/search/docsearch/DocSearchButton.tsx index 092be7731..9d07c49e2 100644 --- a/src/components/search/docsearch/DocSearchButton.tsx +++ b/src/components/search/docsearch/DocSearchButton.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useSyncExternalStore} from 'react'; import {SearchIcon} from './icons/SearchIcon'; @@ -18,6 +18,8 @@ function isAppleDevice() { return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); } +const subscribe = () => () => {}; + export const DocSearchButton = React.forwardRef< HTMLButtonElement, DocSearchButtonProps @@ -25,23 +27,22 @@ export const DocSearchButton = React.forwardRef< const {buttonText = 'Search docs...', buttonAriaLabel = 'Search docs...'} = translations; - let [modifierKey, setModifierKey] = useState(); - - useEffect(() => { - const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); - setModifierKey(isMac ? '⌘' : 'Ctrl'); - }, []); + const modifierKey = useSyncExternalStore( + subscribe, + () => (isAppleDevice() ? ACTION_KEY_APPLE : ACTION_KEY_DEFAULT), + () => ACTION_KEY_DEFAULT + ); return ( - {productFilters.map(product => ( - ))} + {productFilters.map(product => ( + + ))} + )} diff --git a/src/components/search/docsearch/NoResultsScreen.tsx b/src/components/search/docsearch/NoResultsScreen.tsx index cc5b98446..a378d7040 100644 --- a/src/components/search/docsearch/NoResultsScreen.tsx +++ b/src/components/search/docsearch/NoResultsScreen.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import {productFilterLinks} from '../../../data/search'; import type {ScreenStateProps} from './ScreenState'; import type {InternalDocSearchHit} from './types'; @@ -17,6 +18,8 @@ type NoResultsScreenProps = Omit< translations?: NoResultsScreenTranslations; }; +const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''; + export function NoResultsScreen({ translations = {}, ...props @@ -29,6 +32,10 @@ export function NoResultsScreen({ } = translations; const searchSuggestions: string[] | undefined = props.state.context .searchSuggestions as string[]; + const linkedSuggestions = searchSuggestions + ?.map(title => ({title, href: productFilterLinks[title]})) + .filter(suggestion => suggestion.href) + .slice(0, 3); return (
@@ -39,34 +46,22 @@ export function NoResultsScreen({ {noResultsText} "{props.state.query}"

- {searchSuggestions && searchSuggestions.length > 0 && ( + {linkedSuggestions && linkedSuggestions.length > 0 && (
-

{suggestedQueryText}:

+

+ {suggestedQueryText} these products: +

    - {searchSuggestions - .slice(0, 3) - .reduce( - (acc, search) => [ - ...acc, -
  • - -
  • - ], - [] - )} + {linkedSuggestions.map(suggestion => ( +
  • + + {suggestion.title} + +
  • + ))}
)} diff --git a/src/components/search/docsearch/Results.tsx b/src/components/search/docsearch/Results.tsx index bff33684c..fcf141329 100644 --- a/src/components/search/docsearch/Results.tsx +++ b/src/components/search/docsearch/Results.tsx @@ -48,7 +48,10 @@ function HitPath({hit}: {hit: StoredDocSearchHit}) { {levels.map((level, index) => ( {index > 0 && ( - + {' › '} )} @@ -59,13 +62,12 @@ function HitPath({hit}: {hit: StoredDocSearchHit}) { ); } -interface ResultsProps - extends AutocompleteApi< - TItem, - React.FormEvent, - React.MouseEvent, - React.KeyboardEvent - > { +interface ResultsProps extends AutocompleteApi< + TItem, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent +> { title: string; collection: AutocompleteState['collections'][0]; renderIcon: (props: {item: TItem; index: number}) => React.ReactNode; @@ -122,17 +124,17 @@ function Result({ }: ResultProps) { const [isDeleting, setIsDeleting] = React.useState(false); const [isFavoriting, setIsFavoriting] = React.useState(false); - const action = React.useRef<(() => void) | null>(null); + const [action, setAction] = React.useState<(() => void) | null>(null); const Hit = hitComponent!; function runDeleteTransition(cb: () => void) { setIsDeleting(true); - action.current = cb; + setAction(() => cb); } function runFavoriteTransition(cb: () => void) { setIsFavoriting(true); - action.current = cb; + setAction(() => cb); } return ( @@ -147,8 +149,9 @@ function Result({ .filter(Boolean) .join(' ')} onTransitionEnd={() => { - if (action.current) { - action.current(); + if (action) { + action(); + setAction(null); } }} {...getItemProps({ diff --git a/src/components/search/docsearch/SearchBox.tsx b/src/components/search/docsearch/SearchBox.tsx index c7536a465..4403091ae 100644 --- a/src/components/search/docsearch/SearchBox.tsx +++ b/src/components/search/docsearch/SearchBox.tsx @@ -20,13 +20,12 @@ export type SearchBoxTranslations = Partial<{ searchInputLabel: string; }>; -interface SearchBoxProps - extends AutocompleteApi< - InternalDocSearchHit, - React.FormEvent, - React.MouseEvent, - React.KeyboardEvent - > { +interface SearchBoxProps extends AutocompleteApi< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent +> { state: AutocompleteState; autoFocus: boolean; inputRef: MutableRefObject; @@ -35,7 +34,17 @@ interface SearchBoxProps translations?: SearchBoxTranslations; } -export function SearchBox({translations = {}, ...props}: SearchBoxProps) { +export function SearchBox({ + translations = {}, + inputRef, + autoFocus, + isFromSelection, + onClose, + state, + getFormProps, + getInputProps, + getLabelProps +}: SearchBoxProps) { const { resetButtonTitle = 'Clear the query', resetButtonAriaLabel = 'Clear the query', @@ -43,21 +52,28 @@ export function SearchBox({translations = {}, ...props}: SearchBoxProps) { cancelButtonAriaLabel = 'Cancel', searchInputLabel = 'Search' } = translations; - const {onReset} = props.getFormProps({ - inputElement: props.inputRef.current - }); + const [inputElement, setInputElement] = + React.useState(null); + const setInputRef = React.useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + setInputElement(element); + }, + [inputRef] + ); + const {onReset} = getFormProps({inputElement}); React.useEffect(() => { - if (props.autoFocus && props.inputRef.current) { - props.inputRef.current.focus(); + if (autoFocus && inputRef.current) { + inputRef.current.focus(); } - }, [props.autoFocus, props.inputRef]); + }, [autoFocus, inputRef]); React.useEffect(() => { - if (props.isFromSelection && props.inputRef.current) { - props.inputRef.current.select(); + if (isFromSelection && inputRef.current) { + inputRef.current.select(); } - }, [props.isFromSelection, props.inputRef]); + }, [isFromSelection, inputRef]); return ( <> @@ -70,7 +86,7 @@ export function SearchBox({translations = {}, ...props}: SearchBoxProps) { onReset={onReset} > {/*