diff --git a/package-lock.json b/package-lock.json index 694b50cb..64dab7a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "gray-matter": "^4.0.3", "lucide-react": "^0.575.0", "mermaid": "^11.12.3", + "minisearch": "^7.2.0", "next": "^16.1.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -3807,6 +3808,12 @@ ], "license": "MIT" }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", diff --git a/package.json b/package.json index 0164ec97..5d3e2541 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "gray-matter": "^4.0.3", "lucide-react": "^0.575.0", "mermaid": "^11.12.3", + "minisearch": "^7.2.0", "next": "^16.1.6", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/app/search-index.json/route.ts b/src/app/search-index.json/route.ts new file mode 100644 index 00000000..0d03cf85 --- /dev/null +++ b/src/app/search-index.json/route.ts @@ -0,0 +1,10 @@ +import { DOCS_DIRECTORY } from "@/lib/docs/config"; +import { buildDocsSearchIndex } from "@/lib/search/build-index"; + +// Generate the index once at build time and serve it as a static file. +export const dynamic = "force-static"; + +// GET returns every docs section as JSON for the client-side search dialog. +export async function GET(): Promise { + return Response.json(await buildDocsSearchIndex(DOCS_DIRECTORY)); +} diff --git a/src/components/navbar/Navbar.module.css b/src/components/navbar/Navbar.module.css index e986ff5a..8a07f4a1 100644 --- a/src/components/navbar/Navbar.module.css +++ b/src/components/navbar/Navbar.module.css @@ -16,6 +16,15 @@ justify-content: space-between; align-items: center; + & .actions { + display: flex; + align-items: center; + gap: 30px; + @media(max-width: 768px) { + gap: 8px; + } + } + & .desktopLinks { display: flex; align-items: center; diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index ea0f8fec..4b101df4 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -13,6 +13,7 @@ import NavTree, { type NavTreeNode, } from "../nav-tree"; import { DOCS_PAGES_ROOT_PATH } from "@/lib/docs/config"; +import Search from "../search"; import GhosttyWordmark from "./ghostty-wordmark.svg"; import s from "./Navbar.module.css"; @@ -90,32 +91,35 @@ export default function Navbar({ Ghostty -
- {links && ( - - )} - {cta && ( - - )} +
+ +
+ {links && ( +
    + {links.map((link) => { + return ( +
  • + +
  • + ); + })} +
+ )} + {cta && ( + + )} +
+ setMobileMenuOpen(!mobileMenuOpen)} + />
- setMobileMenuOpen(!mobileMenuOpen)} - />
= { + page: "Page", + section: "Section", + "config-option": "Config", + "keybind-action": "Action", + "vt-sequence": "VT", +}; + +// CODE_KINDS are result kinds whose titles are identifiers, shown in monospace. +const CODE_KINDS = new Set([ + "config-option", + "keybind-action", +]); + +interface SearchProps { + className?: string; +} + +// Search renders the navbar search button and the docs search dialog. +// It opens with the button, Cmd/Ctrl+K, or "/". +export default function Search({ className }: SearchProps) { + const router = useRouter(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + const { engine, failed, prepare } = useDocsSearchEngine(); + const shortcutLabel = useShortcutLabel(); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const results = useMemo(() => engine?.search(query) ?? [], [engine, query]); + + // open shows the dialog and selects any prefilled query. + const open = useCallback(() => { + prepare(); + if (dialogRef.current && !dialogRef.current.open) { + dialogRef.current.showModal(); + inputRef.current?.select(); + } + }, [prepare]); + + // resetSearch clears the query from both the input and the URL. The + // component lives in the shared layout and survives navigation, so without + // this a closed search would keep its old query on every later page. + const resetSearch = useCallback(() => { + setQuery(""); + writeSearchQueryParam(""); + }, []); + + // close hides the dialog and resets the search. The URL is cleared before + // closing so that a navigation to a picked result starts from a clean entry. + const close = useCallback(() => { + resetSearch(); + dialogRef.current?.close(); + }, [resetSearch]); + + // toggle opens or closes the dialog from the keyboard shortcut. + const toggle = useCallback(() => { + if (dialogRef.current?.open) { + close(); + } else { + open(); + } + }, [open, close]); + + useSearchShortcut(toggle); + + // openWithQuery opens the dialog prefilled, e.g. from a `?q=` link. + // While the dialog is open, `?q=` changes come from our own typing being + // mirrored into the URL, so they are ignored to avoid a feedback loop. + const openWithQuery = useCallback( + (prefill: string) => { + if (dialogRef.current?.open) { + return; + } + setQuery(prefill); + open(); + }, + [open], + ); + + // Reset the highlighted result whenever the results change. + useEffect(() => { + setActiveIndex(0); + }, [results]); + + // Keep the highlighted result visible while navigating with the keyboard. + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + // handleInputKeyDown implements arrow-key navigation, Enter to open, and + // Escape to close. Browsers otherwise use the first Escape in a search + // input to clear it, which would take two presses to close the dialog. + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (event.key === "Enter" && results[activeIndex]) { + event.preventDefault(); + close(); + router.push(results[activeIndex].id); + } + } + + const status = statusMessage({ + failed, + loaded: engine !== null, + query, + resultCount: results.length, + }); + + return ( + <> + {/* Search params are only known in the browser on static pages, so this + renders nothing during prerendering. */} + + + + + + + {/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape already closes the native dialog; this only handles backdrop clicks. */} + { + if (event.target === dialogRef.current) { + close(); + } + }} + > +
+
+ + { + setQuery(event.target.value); + writeSearchQueryParam(event.target.value); + }} + onKeyDown={handleInputKeyDown} + autoComplete="off" + spellCheck={false} + /> + Esc +
+ + {status &&

{status}

} + + {results.length > 0 && ( +
    + {results.map((result, i) => ( + + ))} +
+ )} +
+
+ + ); +} + +interface SearchQueryParamProps { + onQuery: (query: string) => void; +} + +// SearchQueryParam opens search when the URL has a `?q=` query parameter. +function SearchQueryParam({ onQuery }: SearchQueryParamProps) { + useSearchQueryParam(onQuery); + return null; +} + +interface SearchResultItemProps { + result: SearchResult; + index: number; + active: boolean; + onHover: (index: number) => void; + onSelect: () => void; +} + +// SearchResultItem renders one result: kind badge, title, location, and snippet. +function SearchResultItem({ + result, + index, + active, + onHover, + onSelect, +}: SearchResultItemProps) { + return ( +
  • onHover(index)} + > + {/* Results change on every keystroke, so prefetching them would fetch + pages the user never opens. */} + +
    + {KIND_LABELS[result.kind]} + + + +
    +
    {result.breadcrumb.join(" › ")}
    + {result.text && ( +

    + +

    + )} +
    +
  • + ); +} + +// statusMessage returns the message shown in place of results, if any. +function statusMessage({ + failed, + loaded, + query, + resultCount, +}: { + failed: boolean; + loaded: boolean; + query: string; + resultCount: number; +}): string | null { + const trimmed = query.trim(); + if (failed) { + return "Search is unavailable right now. Please try again later."; + } + if (!trimmed) { + return null; + } + if (!loaded) { + return "Loading…"; + } + return resultCount === 0 ? `No results for “${trimmed}”` : null; +} + +interface HighlightProps { + text: string; + terms: string[]; +} + +// Highlight renders text with every occurrence of the matched terms in . +function Highlight({ text, terms }: HighlightProps) { + if (terms.length === 0) { + return text; + } + // split with a capture group puts the matches at odd indices. + return text + .split(termsPattern(terms)) + .map((part, i) => + i % 2 === 1 ? {part} : part, + ); +} diff --git a/src/lib/docs/heading-ids.mjs b/src/lib/docs/heading-ids.mjs new file mode 100644 index 00000000..81ecb7ce --- /dev/null +++ b/src/lib/docs/heading-ids.mjs @@ -0,0 +1,29 @@ +import slugify from "slugify"; + +// headingText returns the plain text of a heading node from its literal children. +export function headingText(headingNode) { + if (!Array.isArray(headingNode.children)) { + return ""; + } + return headingNode.children + .map((child) => (typeof child.value === "string" ? child.value : "")) + .join(""); +} + +// createHeadingIdResolver returns a function that maps heading text to a page-unique ID. +// Repeated headings get a numeric suffix, e.g. "foo", "foo-2", "foo-3". +// This must be shared by everything that links to headings (rendered +// pages, the sidecar, and the search index) so anchors stay in sync. +export function createHeadingIdResolver() { + const encounteredIDs = new Map(); + + return (text) => { + const baseId = slugify(text.toLowerCase()); + const index = (encounteredIDs.get(baseId) || 0) + 1; + encounteredIDs.set(baseId, index); + return { + id: index >= 2 ? `${baseId}-${index}` : baseId, + index, + }; + }; +} diff --git a/src/lib/docs/mdx.ts b/src/lib/docs/mdx.ts new file mode 100644 index 00000000..8a82cb17 --- /dev/null +++ b/src/lib/docs/mdx.ts @@ -0,0 +1,13 @@ +import type { Root } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +// mdxParser parses MDX source into an mdast tree with GFM and MDX syntax enabled. +const mdxParser = unified().use(remarkParse).use(remarkMdx).use(remarkGfm); + +// parseMdx parses docs MDX source (without frontmatter) into an mdast tree. +export function parseMdx(source: string): Root { + return mdxParser.parse(source); +} diff --git a/src/lib/docs/page.ts b/src/lib/docs/page.ts index 48407b62..975af5f3 100644 --- a/src/lib/docs/page.ts +++ b/src/lib/docs/page.ts @@ -1,13 +1,10 @@ import matter from "gray-matter"; import { promises as fs } from "node:fs"; import { createElement, type ComponentType, type ReactNode } from "react"; -import remarkMdx from "remark-mdx"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; -import slugify from "slugify"; -import { unified } from "unified"; -import type { Node } from "unist"; +import type { Heading } from "mdast"; import { visit } from "unist-util-visit"; +import { createHeadingIdResolver, headingText } from "./heading-ids.mjs"; +import { parseMdx } from "./mdx"; const nodePath = require("node:path"); @@ -40,25 +37,29 @@ export async function loadDocsPage( docsDirectory: string, slug: string, ): Promise { - // A file with a given slug can be located in one of two places. - // First we attempt to load the file from the non-index path first. - // e.g. `/docs/foo.mdx` will be tried before `/docs/foo/index.mdx`. + return await loadDocsPageFromRelativeFilePath( + await resolveDocsPageFilePath(docsDirectory, slug), + ); +} + +// resolveDocsPageFilePath returns the MDX file path that backs a docs slug. +// A file with a given slug can be located in one of two places, and the +// non-index path wins: `/docs/foo.mdx` is tried before `/docs/foo/index.mdx`. +// When neither exists, the index path is returned so reading it fails with ENOENT. +export async function resolveDocsPageFilePath( + docsDirectory: string, + slug: string, +): Promise { + const directPath = nodePath.join(docsDirectory, slug + MDX_EXTENSION); try { - return await loadDocsPageFromRelativeFilePath( - nodePath.join(docsDirectory, slug + MDX_EXTENSION), - ); + await fs.access(directPath); + return directPath; } catch (err) { - // If we run into an error because the file didn't exist catch this error - // because we're going to check the other possible location. if (!isErrorWithCode(err) || err.code !== "ENOENT") { - // Some other unexpected error occurred throw err; } } - // Now we'll attempt to load the index file path. - return await loadDocsPageFromRelativeFilePath( - nodePath.join(docsDirectory, slug, `index${MDX_EXTENSION}`), - ); + return nodePath.join(docsDirectory, slug, `index${MDX_EXTENSION}`); } // loadDocsPageFromRelativeFilePath compiles one MDX file and extracts docs metadata. @@ -67,7 +68,7 @@ async function loadDocsPageFromRelativeFilePath( ): Promise { const mdxFileContent = matter.read(relativeFilePath); const slug = slugFromRelativeFilePath(relativeFilePath); - const pageHeaders = await extractPageHeaders(mdxFileContent.content); + const pageHeaders = extractPageHeaders(mdxFileContent.content); const MdxContent = await loadMdxComponent(relativeFilePath); return { slug, @@ -104,91 +105,25 @@ async function loadMdxComponent( } // extractPageHeaders parses MDX source and returns stable heading metadata. -async function extractPageHeaders(source: string): Promise { +// IDs come from the same resolver used by remark-heading-ids.mjs at render +// time, so sidecar links always point at existing anchors. +function extractPageHeaders(source: string): PageHeader[] { const pageHeaders: PageHeader[] = []; - const processor = unified() - .use(remarkParse) - .use(remarkMdx) - .use(remarkGfm) - .use(parseAnchorLinks({ pageHeaders })); - const tree = processor.parse(source); - await processor.run(tree); + const resolveHeadingId = createHeadingIdResolver(); + visit(parseMdx(source), "heading", (heading: Heading) => { + if (heading.children.length === 0) { + return; + } + const title = headingText(heading); + pageHeaders.push({ + depth: heading.depth, + id: resolveHeadingId(title).id, + title, + }); + }); return pageHeaders; } -// parseAnchorLinks captures headings into pageHeaders and assigns stable heading IDs. -function parseAnchorLinks({ - pageHeaders, -}: { - pageHeaders: PageHeader[]; -}): () => (node: Node) => void { - type HeadingNode = { - type: "heading"; - depth: number; - children: { - type: string; - value: string; - }[]; - data?: { - hProperties?: Record; - }; - }; - - return () => { - // We need to keep track of how many times that we have encountered a - // given header ID, as to ensure that we don't run into any conflicts. - // If there is a conflict, the sidecar will run into issues, and only - // the first header will be able to be deep-linked to. - // - // In the event that we encounter a duplicate Header ID, we'll simply - // add a suffix to the ID to make it unique. e.g. if there are two headers - // with the same name "Foo", the first ID will be "foo", while the second - // will be "foo-2". - const encounteredIDs = new Map(); - - return (node: Node) => { - visit(node, "heading", (visitedNode: Node) => { - if (visitedNode.type === "heading") { - const headingNode = visitedNode as HeadingNode; - if (headingNode.children.length > 0) { - const text = headingNode.children.map((v) => v.value).join(""); - const baseId = slugify(text.toLowerCase()); - - // If this is not the first occurrence, add a data-index attribute - const encounteredCount = (encounteredIDs.get(baseId) || 0) + 1; - encounteredIDs.set(baseId, encounteredCount); - if (encounteredCount >= 2) { - if (!headingNode.data) { - headingNode.data = {}; - } - headingNode.data.hProperties = { - ...headingNode.data.hProperties, - "data-index": encounteredCount.toString(), - }; - } - const resolvedID = - encounteredCount >= 2 ? `${baseId}-${encounteredCount}` : baseId; - - if (!headingNode.data) { - headingNode.data = {}; - } - headingNode.data.hProperties = { - ...headingNode.data.hProperties, - id: resolvedID, - }; - - pageHeaders.push({ - depth: headingNode.depth, - id: resolvedID, - title: text, - }); - } - } - }); - }; - }; -} - // loadAllDocsPageSlugs recursively discovers docs MDX files and returns their slugs. export async function loadAllDocsPageSlugs( docsDirectory: string, diff --git a/src/lib/docs/remark-heading-ids.mjs b/src/lib/docs/remark-heading-ids.mjs index b8d832e5..95ae1d37 100644 --- a/src/lib/docs/remark-heading-ids.mjs +++ b/src/lib/docs/remark-heading-ids.mjs @@ -1,37 +1,31 @@ -import slugify from "slugify"; import { visit } from "unist-util-visit"; +import { createHeadingIdResolver, headingText } from "./heading-ids.mjs"; // remarkHeadingIds applies stable IDs and de-duplication indices to heading nodes. export default function remarkHeadingIds() { return (node) => { - // encounteredIDs tracks duplicate IDs so each heading receives a unique anchor. - const encounteredIDs = new Map(); + // resolveHeadingId de-duplicates IDs so each heading receives a unique anchor. + const resolveHeadingId = createHeadingIdResolver(); visit(node, "heading", (headingNode) => { if (!Array.isArray(headingNode.children) || headingNode.children.length === 0) { return; } - const text = headingNode.children - .map((child) => (typeof child.value === "string" ? child.value : "")) - .join(""); - const baseId = slugify(text.toLowerCase()); - const encounteredCount = (encounteredIDs.get(baseId) || 0) + 1; - encounteredIDs.set(baseId, encounteredCount); - const resolvedID = encounteredCount >= 2 ? `${baseId}-${encounteredCount}` : baseId; + const { id, index } = resolveHeadingId(headingText(headingNode)); if (!headingNode.data) { headingNode.data = {}; } headingNode.data.hProperties = { ...headingNode.data.hProperties, - id: resolvedID, + id, }; - if (encounteredCount >= 2) { + if (index >= 2) { headingNode.data.hProperties = { ...headingNode.data.hProperties, - "data-index": encounteredCount.toString(), + "data-index": index.toString(), }; } }); diff --git a/src/lib/search/build-index.ts b/src/lib/search/build-index.ts new file mode 100644 index 00000000..37fc6e07 --- /dev/null +++ b/src/lib/search/build-index.ts @@ -0,0 +1,197 @@ +import matter from "gray-matter"; +import type { NavTreeNode } from "@/components/nav-tree"; +import { DOCS_PAGES_ROOT_PATH } from "@/lib/docs/config"; +import { + createHeadingIdResolver, + headingText, +} from "@/lib/docs/heading-ids.mjs"; +import { parseMdx } from "@/lib/docs/mdx"; +import { + loadDocsNavTreeData, + navTreeToBreadcrumbs, +} from "@/lib/docs/navigation"; +import { loadAllDocsPageSlugs, resolveDocsPageFilePath } from "@/lib/docs/page"; +import type { SearchSection, SearchSectionKind } from "./types"; + +// MAX_SECTION_TEXT_LENGTH caps indexed text per section to keep the index small. +const MAX_SECTION_TEXT_LENGTH = 4000; + +// SKIPPED_NODE_TYPES are MDX nodes whose contents are code, not readable prose. +const SKIPPED_NODE_TYPES = new Set([ + "mdxjsEsm", + "mdxFlowExpression", + "mdxTextExpression", +]); + +// MdxNode is the structural subset of mdast and MDX JSX nodes the indexer reads. +type MdxNode = { + type: string; + value?: unknown; + depth?: number; + name?: string | null; + attributes?: Array<{ name?: string; value?: unknown }>; + children?: MdxNode[]; +}; + +// DocsPageSource is the raw input needed to index one docs page. +type DocsPageSource = { + slug: string; + url: string; + title: string; + breadcrumb: string[]; + content: string; +}; + +// HeadingChunk is a heading (or the page intro, when null) plus the nodes under it. +type HeadingChunk = { + heading: MdxNode | null; + nodes: MdxNode[]; +}; + +// buildDocsSearchIndex reads every docs page and splits it into heading-level sections. +export async function buildDocsSearchIndex( + docsDirectory: string, +): Promise { + const navTree = await loadDocsNavTreeData(docsDirectory, ""); + const slugs = (await loadAllDocsPageSlugs(docsDirectory)).sort(); + const pages = await Promise.all( + slugs.map((slug) => loadDocsPageSource(docsDirectory, navTree, slug)), + ); + return pages.flatMap(pageToSections); +} + +// loadDocsPageSource reads one page's frontmatter, body, and nav location. +async function loadDocsPageSource( + docsDirectory: string, + navTree: NavTreeNode[], + slug: string, +): Promise { + const { data, content } = matter.read( + await resolveDocsPageFilePath(docsDirectory, slug), + ); + const title = String(data.title ?? slug); + const breadcrumb = navTreeToBreadcrumbs( + "Ghostty Docs", + DOCS_PAGES_ROOT_PATH, + navTree, + slug, + ) + .slice(1) + .map((crumb) => crumb.text); + + return { + slug, + url: + slug === "index" + ? DOCS_PAGES_ROOT_PATH + : `${DOCS_PAGES_ROOT_PATH}/${slug}`, + title, + breadcrumb: breadcrumb.length > 0 ? breadcrumb : [title], + content, + }; +} + +// pageToSections turns one page into a page-intro section plus one section per heading. +function pageToSections(page: DocsPageSource): SearchSection[] { + const tree = parseMdx(page.content) as MdxNode; + const resolveHeadingId = createHeadingIdResolver(); + + const sections = chunkByHeading(tree).map(({ heading, nodes }) => { + const title = heading ? headingText(heading) : page.title; + return { + id: heading ? `${page.url}#${resolveHeadingId(title).id}` : page.url, + kind: sectionKind(page.slug, heading, tree), + title, + breadcrumb: page.breadcrumb, + keywords: nodes.flatMap(extractVTSequences).join(" "), + text: normalizeWhitespace(nodes.map(extractText).join(" ")).slice( + 0, + MAX_SECTION_TEXT_LENGTH, + ), + }; + }); + + return inheritSharedDescriptions(sections); +} + +// chunkByHeading splits a page's top-level nodes at every non-empty heading. +// The first chunk is the page intro and has no heading. +function chunkByHeading(tree: MdxNode): HeadingChunk[] { + const chunks: HeadingChunk[] = [{ heading: null, nodes: [] }]; + for (const node of tree.children ?? []) { + if (node.type === "heading" && (node.children?.length ?? 0) > 0) { + chunks.push({ heading: node, nodes: [] }); + } else { + chunks[chunks.length - 1].nodes.push(node); + } + } + return chunks; +} + +// sectionKind classifies a section by the page it is on and its heading level. +function sectionKind( + slug: string, + heading: MdxNode | null, + tree: MdxNode, +): SearchSectionKind { + if (!heading) { + return slug.startsWith("vt/") && extractVTSequences(tree).length > 0 + ? "vt-sequence" + : "page"; + } + if (heading.depth === 2 && slug === "config/reference") { + return "config-option"; + } + if (heading.depth === 2 && slug === "config/keybind/reference") { + return "keybind-action"; + } + return "section"; +} + +// inheritSharedDescriptions fills empty sections from the section that follows. +// Generated references list related headings back to back with one shared +// description under the last one (e.g. `font-family`, `font-family-bold`). +function inheritSharedDescriptions(sections: SearchSection[]): SearchSection[] { + for (let i = sections.length - 2; i >= 1; i--) { + if (!sections[i].text && sections[i].kind === sections[i + 1].kind) { + sections[i].text = sections[i + 1].text; + } + } + return sections; +} + +// extractText returns the readable text of a node, including code and JSX children. +function extractText(node: MdxNode): string { + if (SKIPPED_NODE_TYPES.has(node.type)) { + return ""; + } + if (typeof node.value === "string") { + return node.value; + } + return (node.children ?? []).map(extractText).join(" "); +} + +// extractVTSequences returns the parts of every VTSequence element in a node, +// e.g. `` becomes "CSI Py ; Px H". +function extractVTSequences(node: MdxNode): string[] { + const nested = (node.children ?? []).flatMap(extractVTSequences); + if (node.name !== "VTSequence") { + return nested; + } + const attribute = node.attributes?.find((attr) => attr.name === "sequence"); + // The attribute is either a plain string or a JS expression like `["CSI", "H"]`. + const raw = + typeof attribute?.value === "string" + ? attribute.value + : (attribute?.value as { value?: unknown } | undefined)?.value; + if (typeof raw !== "string") { + return nested; + } + const parts = Array.from(raw.matchAll(/"([^"]*)"/g), (match) => match[1]); + return [(parts.length > 0 ? parts : [raw]).join(" "), ...nested]; +} + +// normalizeWhitespace collapses runs of whitespace into single spaces. +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} diff --git a/src/lib/search/engine.ts b/src/lib/search/engine.ts new file mode 100644 index 00000000..d38f63de --- /dev/null +++ b/src/lib/search/engine.ts @@ -0,0 +1,127 @@ +import type MiniSearch from "minisearch"; +import type { SearchOptions } from "minisearch"; +import { SEARCH_INDEX_PATH, type SearchSection } from "./types"; + +// MAX_RESULTS limits how many results one query returns. +const MAX_RESULTS = 20; + +// EXACT_MATCH_BOOST multiplies the score of sections whose title or VT +// sequence matches the whole query, e.g. typing a full config key. +const EXACT_MATCH_BOOST = 10; + +// INDEXED_FIELDS are the section fields MiniSearch tokenizes. breadcrumbPath +// is derived from breadcrumb so the docs location is searchable too. +const INDEXED_FIELDS = ["title", "keywords", "breadcrumbPath", "text"]; + +// FIELD_BOOSTS weight matches by where they occur; unlisted fields weigh 1. +const FIELD_BOOSTS = { title: 4, keywords: 3, breadcrumbPath: 1.5 }; + +// SearchResult is a matched section plus the indexed terms it matched on. +export type SearchResult = SearchSection & { terms: string[] }; + +// DocsSearchEngine wraps MiniSearch with the docs index and ranking rules. +export class DocsSearchEngine { + // loading caches the in-flight or finished load so it happens once per page. + private static loading: Promise | null = null; + + private constructor( + private readonly index: MiniSearch, + private readonly sections: Map, + ) {} + + // load fetches the static index and MiniSearch on first use and builds the engine. + static load(): Promise { + if (!DocsSearchEngine.loading) { + DocsSearchEngine.loading = DocsSearchEngine.create().catch((err) => { + // Allow a retry on the next call instead of caching the failure. + DocsSearchEngine.loading = null; + throw err; + }); + } + return DocsSearchEngine.loading; + } + + // create downloads the index and the MiniSearch module in parallel. + private static async create(): Promise { + const [{ default: MiniSearchEngine }, sections] = await Promise.all([ + import("minisearch"), + fetchSections(), + ]); + const index = new MiniSearchEngine({ + fields: INDEXED_FIELDS, + extractField: (section, field) => + field === "breadcrumbPath" + ? section.breadcrumb.join(" ") + : String(section[field as keyof SearchSection] ?? ""), + }); + index.addAll(sections); + return new DocsSearchEngine( + index, + new Map(sections.map((section) => [section.id, section])), + ); + } + + // search returns the best sections for a query. Results must match every + // term; if nothing does, it falls back to matching any term. + search(query: string): SearchResult[] { + if (!query.trim()) { + return []; + } + const options = this.searchOptions(query); + let hits = this.index.search(query, { ...options, combineWith: "AND" }); + if (hits.length === 0) { + hits = this.index.search(query, { ...options, combineWith: "OR" }); + } + return hits.slice(0, MAX_RESULTS).flatMap((hit) => { + const section = this.sections.get(String(hit.id)); + return section ? [{ ...section, terms: hit.terms }] : []; + }); + } + + // searchOptions builds the MiniSearch ranking options for one query. + private searchOptions(query: string): SearchOptions { + const identifier = normalizeIdentifier(query); + const sequence = normalizeSequence(query); + return { + prefix: true, + // Short terms like "csi" would fuzzy-match too many unrelated words. + fuzzy: (term) => (term.length > 4 ? 0.2 : false), + boost: FIELD_BOOSTS, + boostDocument: (id) => { + const section = this.sections.get(String(id)); + const isExact = + section !== undefined && + (normalizeIdentifier(section.title) === identifier || + (section.keywords !== "" && + normalizeSequence(section.keywords) === sequence)); + return isExact ? EXACT_MATCH_BOOST : 1; + }, + }; + } +} + +// fetchSections downloads the static search index. +async function fetchSections(): Promise { + const res = await fetch(SEARCH_INDEX_PATH); + if (!res.ok) { + throw new Error(`Failed to load search index: ${res.status}`); + } + return res.json(); +} + +// normalizeIdentifier makes "font family", "font-family" and "font_family" compare equal. +function normalizeIdentifier(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-"); +} + +// normalizeSequence reduces a VT sequence to its fixed bytes, dropping +// parameter placeholders, so "CSI H" matches "CSI Py ; Px H". +function normalizeSequence(text: string): string { + return text + .replace(/\bP[a-z]\b/g, "") + .replace(/[\s;]+/g, "") + .toLowerCase(); +} diff --git a/src/lib/search/hooks.ts b/src/lib/search/hooks.ts new file mode 100644 index 00000000..04935032 --- /dev/null +++ b/src/lib/search/hooks.ts @@ -0,0 +1,76 @@ +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { DocsSearchEngine } from "./engine"; +import { SEARCH_QUERY_PARAM } from "./query-param"; + +// useDocsSearchEngine exposes the lazily loaded search engine. +// Call prepare to start loading, e.g. when the user hovers the search button. +export function useDocsSearchEngine(): { + engine: DocsSearchEngine | null; + failed: boolean; + prepare: () => void; +} { + const [engine, setEngine] = useState(null); + const [failed, setFailed] = useState(false); + + const prepare = useCallback(() => { + DocsSearchEngine.load().then( + (loaded) => { + setEngine(loaded); + setFailed(false); + }, + () => setFailed(true), + ); + }, []); + + return { engine, failed, prepare }; +} + +// useSearchShortcut calls onToggle on Cmd/Ctrl+K, or "/" outside text fields. +export function useSearchShortcut(onToggle: () => void): void { + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const isModK = + (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"; + const isSlash = event.key === "/" && !isTypingTarget(event.target); + if (isModK || isSlash) { + event.preventDefault(); + onToggle(); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onToggle]); +} + +// useSearchQueryParam calls onQuery with the `?q=` value whenever it is set or changes. +// It reads search params, so the calling component must be inside . +export function useSearchQueryParam(onQuery: (query: string) => void): void { + const query = useSearchParams().get(SEARCH_QUERY_PARAM); + useEffect(() => { + if (query) { + onQuery(query); + } + }, [query, onQuery]); +} + +// useShortcutLabel returns the platform's label for the search shortcut. +// The platform is only known on the client, so it starts as "Ctrl K". +export function useShortcutLabel(): string { + const [label, setLabel] = useState("Ctrl K"); + useEffect(() => { + if (/Mac|iPhone|iPad/.test(navigator.platform)) { + setLabel("⌘K"); + } + }, []); + return label; +} + +// isTypingTarget reports whether a key event originated from an editable element. +function isTypingTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) + ); +} diff --git a/src/lib/search/query-param.ts b/src/lib/search/query-param.ts new file mode 100644 index 00000000..05c0e731 --- /dev/null +++ b/src/lib/search/query-param.ts @@ -0,0 +1,20 @@ +// SEARCH_QUERY_PARAM is the URL query parameter that holds the search query, +// e.g. `/docs?q=font-family` opens search prefilled. +export const SEARCH_QUERY_PARAM = "q"; + +// writeSearchQueryParam mirrors the query into the current URL, or removes it +// when empty. It uses replaceState so typing neither adds history entries nor +// triggers a Next.js navigation; the path and #anchor are preserved. +export function writeSearchQueryParam(query: string): void { + const url = new URL(window.location.href); + if ((url.searchParams.get(SEARCH_QUERY_PARAM) ?? "") === query) { + return; + } + if (query) { + url.searchParams.set(SEARCH_QUERY_PARAM, query); + } else { + url.searchParams.delete(SEARCH_QUERY_PARAM); + } + // Keep the existing history state, which Next.js uses for its router. + window.history.replaceState(window.history.state, "", url); +} diff --git a/src/lib/search/snippet.ts b/src/lib/search/snippet.ts new file mode 100644 index 00000000..0bfc1a42 --- /dev/null +++ b/src/lib/search/snippet.ts @@ -0,0 +1,33 @@ +// SNIPPET_CONTEXT_BEFORE/AFTER control how much text surrounds the first match. +const SNIPPET_CONTEXT_BEFORE = 40; +const SNIPPET_CONTEXT_AFTER = 120; + +// snippet returns the slice of text around the first matched term. +export function snippet(text: string, terms: string[]): string { + const lowerText = text.toLowerCase(); + const positions = terms + .map((term) => lowerText.indexOf(term.toLowerCase())) + .filter((pos) => pos >= 0); + const first = positions.length > 0 ? Math.min(...positions) : 0; + const start = Math.max(0, first - SNIPPET_CONTEXT_BEFORE); + const end = Math.min(text.length, first + SNIPPET_CONTEXT_AFTER); + const prefix = start > 0 ? "…" : ""; + const suffix = end < text.length ? "…" : ""; + return `${prefix}${text.slice(start, end)}${suffix}`; +} + +// termsPattern returns a case-insensitive RegExp that captures any of the terms. +// Longest terms come first so "fonts" wins over "font" in the alternation. +export function termsPattern(terms: string[]): RegExp { + const alternation = terms + .slice() + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join("|"); + return new RegExp(`(${alternation})`, "gi"); +} + +// escapeRegExp escapes a string for literal use inside a RegExp. +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts new file mode 100644 index 00000000..5a836c01 --- /dev/null +++ b/src/lib/search/types.ts @@ -0,0 +1,24 @@ +// SEARCH_INDEX_PATH is the URL of the statically generated docs search index. +export const SEARCH_INDEX_PATH = "/search-index.json"; + +// SearchSectionKind classifies a search result so the UI can label it. +export type SearchSectionKind = + | "page" + | "section" + | "config-option" + | "keybind-action" + | "vt-sequence"; + +// SearchSection is one searchable unit: a page intro or the content under a heading. +export type SearchSection = { + // id is the section URL, which is unique across the site. + id: string; + kind: SearchSectionKind; + // title is the heading text, or the page title for a page intro. + title: string; + // breadcrumb is the human-readable location of the page in the docs nav. + breadcrumb: string[]; + // keywords holds extra terms that are not visible as prose, e.g. VT sequences. + keywords: string; + text: string; +};