From 594ebe0f9b73dfe7e577fd1f72a864796275b8af Mon Sep 17 00:00:00 2001 From: Rae Sharp Date: Thu, 19 Feb 2026 18:57:32 -0500 Subject: [PATCH] Adds pylon KB to algolia search Signed-off-by: Rae Sharp --- .github/workflows/sync-pylon-algolia.yml | 31 ++ scripts/sync-pylon-to-algolia.js | 261 ++++++++++++++ src/theme/SearchBar/index.tsx | 419 +++++++++++++++++++++++ src/theme/SearchBar/styles.css | 25 ++ 4 files changed, 736 insertions(+) create mode 100644 .github/workflows/sync-pylon-algolia.yml create mode 100644 scripts/sync-pylon-to-algolia.js create mode 100644 src/theme/SearchBar/index.tsx create mode 100644 src/theme/SearchBar/styles.css diff --git a/.github/workflows/sync-pylon-algolia.yml b/.github/workflows/sync-pylon-algolia.yml new file mode 100644 index 000000000..2253d8cf8 --- /dev/null +++ b/.github/workflows/sync-pylon-algolia.yml @@ -0,0 +1,31 @@ +name: Sync Pylon KB to Algolia + +on: + schedule: + - cron: '0 3 * * * + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Sync Pylon KB → Algolia + run: npm run sync-pylon + env: + PYLON_API_KEY: ${{ secrets.PYLON_API_KEY }} + ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_INDEX_NAME: ${{ vars.ALGOLIA_INDEX_NAME }} + PYLON_KB_PORTAL_URL: ${{ vars.PYLON_KB_PORTAL_URL }} + PYLON_VISIBILITY: ${{ vars.PYLON_VISIBILITY }} + SYNC_MODE: ${{ vars.SYNC_MODE }} diff --git a/scripts/sync-pylon-to-algolia.js b/scripts/sync-pylon-to-algolia.js new file mode 100644 index 000000000..7b1de0864 --- /dev/null +++ b/scripts/sync-pylon-to-algolia.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +/** + * sync-pylon-to-algolia.js + * + * Pulls articles from the Pylon Knowledge Base API and upserts them into an + * Algolia index. Run once for a full sync, or on a schedule (e.g. cron/CI) to + * keep the index up-to-date as articles are created or updated. + * + * Required environment variables: + * PYLON_API_KEY - Pylon bearer token + * PYLON_KB_ID - Knowledge-base ID to sync (or leave blank to sync all) + * ALGOLIA_APP_ID - Algolia application ID + * ALGOLIA_ADMIN_API_KEY - Algolia Admin API key (write access) + * ALGOLIA_INDEX_NAME - Target index name (e.g. "pylon-kb") + * + * Optional environment variables: + * PYLON_VISIBILITY - Comma-separated list of visibility values to include. + * Valid values: public, customer, internal-only + * Defaults to "public" + * SYNC_MODE - "full" (default) clears stale records; "delta" only upserts + */ + +'use strict'; + +const { algoliasearch } = require('algoliasearch'); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const PYLON_BASE_URL = 'https://api.usepylon.com'; +const PYLON_API_KEY = process.env.PYLON_API_KEY; +const PYLON_KB_ID = process.env.PYLON_KB_ID; // optional – empty means all KBs +const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID; +const ALGOLIA_ADMIN = process.env.ALGOLIA_ADMIN_API_KEY; +const INDEX_NAME = process.env.ALGOLIA_INDEX_NAME || 'pylon-kb'; +const ALLOWED_VIS = (process.env.PYLON_VISIBILITY || 'public') + .split(',') + .map(v => v.trim().toLowerCase()); +const SYNC_MODE = (process.env.SYNC_MODE || 'full').toLowerCase(); +// Base URL for the Pylon KB portal (used to make article URLs absolute). +// e.g. https://support.upbound.io +const PYLON_KB_PORTAL = (process.env.PYLON_KB_PORTAL_URL || '').replace(/\/$/, ''); + +// How many articles to request per page (API max is unspecified; 100 is safe) +const PAGE_LIMIT = 100; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- +function assertEnv() { + const missing = []; + if (!PYLON_API_KEY) missing.push('PYLON_API_KEY'); + if (!ALGOLIA_APP_ID) missing.push('ALGOLIA_APP_ID'); + if (!ALGOLIA_ADMIN) missing.push('ALGOLIA_ADMIN_API_KEY'); + if (missing.length) { + console.error(`Missing required environment variables: ${missing.join(', ')}`); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Pylon API helpers +// --------------------------------------------------------------------------- +async function pylonFetch(path) { + const url = `${PYLON_BASE_URL}${path}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${PYLON_API_KEY}`, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Pylon API error ${res.status} for ${url}: ${body}`); + } + + return res.json(); +} + +/** Return an array of all knowledge-base objects. */ +async function fetchKnowledgeBases() { + const data = await pylonFetch('/knowledge-bases'); + // The API returns either an array or { data: [...] } + return Array.isArray(data) ? data : (data.data || []); +} + +/** Fetch every article in a knowledge base, following cursor pagination. */ +async function fetchAllArticles(kbId) { + const articles = []; + let cursor = null; + let page = 0; + + do { + page++; + const qs = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor) qs.set('cursor', cursor); + + const data = await pylonFetch(`/knowledge-bases/${kbId}/articles?${qs}`); + const items = Array.isArray(data) ? data : (data.data || []); + articles.push(...items); + + // Advance cursor – adapt to whatever shape the API returns + cursor = data.next_cursor || data.cursor || data.meta?.next_cursor || null; + + console.log(` Page ${page}: fetched ${items.length} articles (total so far: ${articles.length})`); + } while (cursor); + + return articles; +} + +// --------------------------------------------------------------------------- +// HTML → plain-text (no external deps required) +// --------------------------------------------------------------------------- +function htmlToText(html) { + if (!html) return ''; + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<\/li>/gi, '\n') + .replace(/<\/h[1-6]>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Build an excerpt (first ~300 chars of plain text content). */ +function excerpt(text, max = 300) { + const clean = text.replace(/\s+/g, ' ').trim(); + return clean.length <= max ? clean : `${clean.slice(0, max)}…`; +} + +// --------------------------------------------------------------------------- +// Ensure article URLs are absolute so Algolia search results link correctly +// --------------------------------------------------------------------------- +function toAbsoluteUrl(url) { + if (!url) return ''; + if (/^https?:\/\//.test(url)) return url; // already absolute + if (PYLON_KB_PORTAL) return `${PYLON_KB_PORTAL}${url.startsWith('/') ? '' : '/'}${url}`; + return url; // no portal URL configured — leave as-is (will log a warning) +} + +// --------------------------------------------------------------------------- +// Transform a Pylon article into an Algolia record +// --------------------------------------------------------------------------- +function toAlgoliaRecord(article, kb) { + const bodyText = htmlToText(article.body || article.content || ''); + + return { + objectID: `pylon-${kb.id}-${article.id}`, + type: 'pylon-kb', + title: article.title || '', + content: bodyText, + excerpt: excerpt(bodyText), + url: toAbsoluteUrl(article.url || article.public_url || ''), + slug: article.slug || '', + knowledge_base_id: kb.id, + knowledge_base_name: kb.name || kb.title || kb.display_name || '', + collection_id: article.collection_id || '', + collection_name: article.collection_name || '', + visibility: article.visibility || 'public', + author: article.author?.name || article.created_by?.name || '', + created_at: article.created_at || '', + updated_at: article.updated_at || '', + // numeric version for Algolia date-range filters + updated_at_ts: article.updated_at + ? Math.floor(new Date(article.updated_at).getTime() / 1000) + : 0, + }; +} + +// --------------------------------------------------------------------------- +// Main sync logic +// --------------------------------------------------------------------------- +async function sync() { + assertEnv(); + + console.log(`\n=== Pylon → Algolia sync ===`); + console.log(`Mode: ${SYNC_MODE}`); + console.log(`Index: ${INDEX_NAME}`); + console.log(`Visibility: ${ALLOWED_VIS.join(', ')}`); + console.log(''); + + // --- 1. Determine which knowledge bases to sync --- + let kbs; + if (PYLON_KB_ID) { + console.log(`Fetching single knowledge base: ${PYLON_KB_ID}`); + const kb = await pylonFetch(`/knowledge-bases/${PYLON_KB_ID}`); + kbs = [kb]; + } else { + console.log('Fetching all knowledge bases…'); + kbs = await fetchKnowledgeBases(); + console.log(`Found ${kbs.length} knowledge base(s)`); + } + + // --- 2. Fetch all articles --- + const allRecords = []; + + for (const kb of kbs) { + const kbLabel = kb.name || kb.title || kb.display_name || kb.id; + console.log(`\nKnowledge base: "${kbLabel}" (${kb.id})`); + // Debug: surface unexpected shapes on first run + if (!kb.name && !kb.title) { + console.log(' [debug] KB keys:', Object.keys(kb).join(', ')); + } + const articles = await fetchAllArticles(kb.id); + + for (const article of articles) { + const vis = (article.visibility || 'public').toLowerCase(); + if (!ALLOWED_VIS.includes(vis)) { + continue; // skip articles not in the allowed visibility list + } + allRecords.push(toAlgoliaRecord(article, kb)); + } + + console.log(` → ${allRecords.length} total records after this KB`); + } + + if (allRecords.length === 0) { + console.warn('\nNo records to index. Check visibility filters and KB IDs.'); + return; + } + + // --- 3. Push to Algolia --- + // algoliasearch v5: no initIndex – all methods are on the client directly + console.log(`\nConnecting to Algolia (app: ${ALGOLIA_APP_ID}, index: ${INDEX_NAME})…`); + const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN); + + if (SYNC_MODE === 'full') { + // Atomically replace all records; stale (deleted) articles are removed. + console.log(`Full sync: replacing all ${allRecords.length} records…`); + const result = await client.replaceAllObjects({ + indexName: INDEX_NAME, + objects: allRecords, + }); + console.log(`✓ Indexed ${result.objectIDs?.length ?? allRecords.length} records.`); + } else { + // Delta: upsert only what we fetched (no stale-record cleanup) + console.log(`Delta sync: upserting ${allRecords.length} records…`); + const result = await client.saveObjects({ + indexName: INDEX_NAME, + objects: allRecords, + }); + console.log(`✓ Upserted ${result.objectIDs?.length ?? allRecords.length} records.`); + } + + console.log('\nSync complete.\n'); +} + +sync().catch(err => { + console.error('Sync failed:', err.message || err); + process.exit(1); +}); diff --git a/src/theme/SearchBar/index.tsx b/src/theme/SearchBar/index.tsx new file mode 100644 index 000000000..5e9af697a --- /dev/null +++ b/src/theme/SearchBar/index.tsx @@ -0,0 +1,419 @@ +/** + * Swizzled SearchBar — federated search across `upbound` + `pylon-kb`. + * + * Every query is fanned out to both Algolia indices simultaneously. + * Pylon KB hits are remapped to the DocSearch hierarchy shape and appended + * to the result set, appearing under a "Knowledge Base" section header. + * + * Based on @docusaurus/theme-search-algolia SearchBar (3.9.x). + * Changes from the original are marked [CHANGE]. + */ + +import React, { + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import {createPortal} from 'react-dom'; +import {DocSearchButton} from '@docsearch/react/button'; +import {useDocSearchKeyboardEvents} from '@docsearch/react/useDocSearchKeyboardEvents'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useHistory} from '@docusaurus/router'; +import { + isRegexpStringMatch, + useSearchLinkCreator, +} from '@docusaurus/theme-common'; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, + useAlgoliaAskAi, + mergeFacetFilters, +} from '@docusaurus/theme-search-algolia/client'; +import Translate from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import translations from '@theme/SearchTranslations'; +import type { + InternalDocSearchHit, + DocSearchModal as DocSearchModalType, + DocSearchModalProps, + StoredDocSearchHit, + DocSearchTransformClient, + DocSearchHit, + DocSearchTranslations, + UseDocSearchKeyboardEventsProps, +} from '@docsearch/react'; +import type {AutocompleteState} from '@algolia/autocomplete-core'; +import type {FacetFilters} from 'algoliasearch/lite'; +import type {ThemeConfigAlgolia} from '@docusaurus/theme-search-algolia'; + +// --------------------------------------------------------------------------- +// [CHANGE] Pylon KB federated search +// --------------------------------------------------------------------------- +const PYLON_INDEX = 'pylon-kb'; +const PYLON_HITS = 5; +const PYLON_PORTAL = 'https://help.upbound.io'; + +function toAbsoluteUrl(url: string): string { + if (!url) return ''; + if (/^https?:\/\//.test(url)) return url; + return `${PYLON_PORTAL}${url.startsWith('/') ? '' : '/'}${url}`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function remapPylonHit(hit: any) { + return { + ...hit, + // Ensure the URL is absolute so the navigator routes it externally. + // The index may still contain relative paths from an earlier sync. + url: toAbsoluteUrl(hit.url ?? ''), + type: 'content' as const, + hierarchy: { + lvl0: 'Knowledge Base', + lvl1: hit.title ?? '', + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + content: + hit.excerpt ?? + (typeof hit.content === 'string' ? hit.content.slice(0, 200) : null), + _highlightResult: { + hierarchy: { + lvl0: {value: 'Knowledge Base', matchLevel: 'none', matchedWords: []}, + lvl1: + hit._highlightResult?.title ?? { + value: hit.title ?? '', + matchLevel: 'none', + matchedWords: [], + }, + }, + content: + hit._highlightResult?.excerpt ?? + hit._highlightResult?.content ?? { + value: hit.excerpt ?? '', + matchLevel: 'none', + matchedWords: [], + }, + }, + _snippetResult: { + content: + hit._snippetResult?.content ?? { + value: (hit.excerpt ?? '').slice(0, 100), + matchLevel: 'none', + }, + }, + }; +} + +function withPylonKB( + searchClient: DocSearchTransformClient, +): DocSearchTransformClient { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalSearch = (searchClient as any).search.bind(searchClient); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (searchClient as any).search = async function (requests: any) { + const isArray = Array.isArray(requests); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const list: any[] = isArray ? requests : (requests?.requests ?? []); + const query: string = + list[0]?.query ?? list[0]?.params?.query ?? ''; + + if (!query.trim()) { + return originalSearch(requests); + } + + const pylonReq = isArray + ? [{ + indexName: PYLON_INDEX, + query, + params: { + hitsPerPage: PYLON_HITS, + attributesToHighlight: ['title', 'excerpt'], + attributesToSnippet: ['content:15'], + }, + }] + : { + requests: [{ + indexName: PYLON_INDEX, + query, + hitsPerPage: PYLON_HITS, + attributesToHighlight: ['title', 'excerpt'], + attributesToSnippet: ['content:15'], + }], + }; + + const [main, pylon] = await Promise.all([ + originalSearch(requests), + originalSearch(pylonReq).catch(() => null), + ]); + + if (!pylon) return main; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pylonHits: any[] = pylon.results?.[0]?.hits ?? []; + if (pylonHits.length === 0) return main; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results: any[] = Array.isArray(main.results) ? [...main.results] : []; + if (results.length > 0) { + results[0] = { + ...results[0], + hits: [...(results[0].hits ?? []), ...pylonHits.map(remapPylonHit)], + nbHits: (results[0].nbHits ?? 0) + pylonHits.length, + }; + } + + return {...main, results}; + }; + + return searchClient; +} + +// --------------------------------------------------------------------------- +// Original SearchBar — minimal changes below +// --------------------------------------------------------------------------- + +type DocSearchProps = Omit & { + contextualSearch?: string; + externalUrlRegex?: string; + searchPagePath: boolean | string; + askAi?: Exclude< + (DocSearchModalProps & {askAi: unknown})['askAi'], + string | undefined + >; +}; + +interface DocSearchV4Props extends DocSearchProps { + indexName: string; + askAi?: ThemeConfigAlgolia['askAi']; + translations?: DocSearchTranslations; +} + +let DocSearchModal: typeof DocSearchModalType | null = null; + +function importDocSearchModalIfNeeded() { + if (DocSearchModal) return Promise.resolve(); + return Promise.all([ + import('@docsearch/react/modal'), + import('@docsearch/react/style'), + import('./styles.css'), + ]).then(([{DocSearchModal: Modal}]) => { + DocSearchModal = Modal; + }); +} + +// [CHANGE] treat any absolute URL (Pylon KB articles) as external so +// React Router doesn't try to handle them as internal SPA paths. +function useNavigator({externalUrlRegex}: Pick) { + const history = useHistory(); + const [navigator] = useState(() => ({ + navigate(params) { + const {itemUrl} = params; + if (/^https?:\/\//.test(itemUrl) || isRegexpStringMatch(externalUrlRegex, itemUrl)) { + window.location.href = itemUrl; + } else { + history.push(itemUrl); + } + }, + })); + return navigator; +} + +function useTransformSearchClient(): DocSearchModalProps['transformSearchClient'] { + const {siteMetadata: {docusaurusVersion}} = useDocusaurusContext(); + return useCallback( + (searchClient: DocSearchTransformClient) => { + searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion); + return searchClient; + }, + [docusaurusVersion], + ); +} + +function useTransformItems(props: Pick) { + const processSearchResultUrl = useSearchResultUrlProcessor(); + const [transformItems] = useState(() => + (items: DocSearchHit[]) => { + if (props.transformItems) return props.transformItems(items); + return items.map((item) => { + // Pylon KB hits have absolute external URLs — don't let + // processSearchResultUrl turn them into relative site paths. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((item as any).objectID?.startsWith('pylon-')) return item; + return {...item, url: processSearchResultUrl(item.url)}; + }); + }, + ); + return transformItems; +} + +function useResultsFooterComponent({closeModal}: {closeModal: () => void}): DocSearchProps['resultsFooterComponent'] { + return useMemo( + () => ({state}) => , + [closeModal], + ); +} + +function Hit({ + hit, + children, +}: { + hit: InternalDocSearchHit | StoredDocSearchHit; + children: ReactNode; +}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((hit as any).objectID?.startsWith('pylon-')) { + // Render as a plain anchor so the browser handles navigation directly, + // bypassing React Router. URL is guaranteed absolute by remapPylonHit. + return {children}; + } + return {children}; +} + +type ResultsFooterProps = { + state: AutocompleteState; + onClose: () => void; +}; + +function ResultsFooter({state, onClose}: ResultsFooterProps) { + const createSearchLink = useSearchLinkCreator(); + return ( + + + {'See all {count} results'} + + + ); +} + +function useSearchParameters({contextualSearch, ...props}: DocSearchProps): DocSearchProps['searchParameters'] { + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); + const configFacetFilters: FacetFilters = props.searchParameters?.facetFilters ?? []; + const facetFilters: FacetFilters = contextualSearch + ? mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters) + : configFacetFilters; + return {...props.searchParameters, facetFilters}; +} + +function DocSearch({externalUrlRegex, ...props}: DocSearchV4Props) { + const navigator = useNavigator({externalUrlRegex}); + const searchParameters = useSearchParameters({...props}); + const transformItems = useTransformItems(props); + const addAgentTransform = useTransformSearchClient(); + + // [CHANGE] chain: add-agent → user-provided (if any) → Pylon KB fan-out + const userTransform = (props as DocSearchV4Props).transformSearchClient; + const transformSearchClient = useCallback( + (client: DocSearchTransformClient) => + withPylonKB( + userTransform ? userTransform(addAgentTransform(client)) : addAgentTransform(client), + ), + [addAgentTransform, userTransform], + ); + + const searchContainer = useRef(null); + const searchButtonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + + const {isAskAiActive, currentPlaceholder, onAskAiToggle, extraAskAiProps} = + useAlgoliaAskAi(props); + + const prepareSearchContainer = useCallback(() => { + if (!searchContainer.current) { + const div = document.createElement('div'); + searchContainer.current = div; + document.body.insertBefore(div, document.body.firstChild); + } + }, []); + + const openModal = useCallback(() => { + prepareSearchContainer(); + importDocSearchModalIfNeeded().then(() => setIsOpen(true)); + }, [prepareSearchContainer]); + + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + setInitialQuery(undefined); + onAskAiToggle(false); + }, [onAskAiToggle]); + + const handleInput = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'f' && (event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + setInitialQuery(event.key); + openModal(); + }, + [openModal], + ); + + const resultsFooterComponent = useResultsFooterComponent({closeModal}); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + isAskAiActive: isAskAiActive ?? false, + onAskAiToggle: onAskAiToggle ?? (() => {}), + } satisfies UseDocSearchKeyboardEventsProps & { + isAskAiActive: boolean; + onAskAiToggle: (askAiToggle: boolean) => void; + } as UseDocSearchKeyboardEventsProps); + + return ( + <> + + + + + + + {isOpen && DocSearchModal && searchContainer.current && + createPortal( + , + searchContainer.current, + )} + + ); +} + +export default function SearchBar(): ReactNode { + const {siteConfig} = useDocusaurusContext(); + return ; +} diff --git a/src/theme/SearchBar/styles.css b/src/theme/SearchBar/styles.css new file mode 100644 index 000000000..78689effe --- /dev/null +++ b/src/theme/SearchBar/styles.css @@ -0,0 +1,25 @@ +/** + * 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. + */ + +:root { + --docsearch-primary-color: var(--ifm-color-primary); + --docsearch-text-color: var(--ifm-font-color-base); +} + +.DocSearch-Button { + margin: 0; + transition: all var(--ifm-transition-fast) + var(--ifm-transition-timing-default); +} + +.DocSearch-Container { + z-index: calc(var(--ifm-z-index-fixed) + 1); +} + +.DocSearch-Button-Key { + padding: 0; +}