diff --git a/app/components/backdrop.tsx b/app/components/backdrop.tsx new file mode 100644 index 0000000..b76b818 --- /dev/null +++ b/app/components/backdrop.tsx @@ -0,0 +1,13 @@ +import { cn } from "~/utils/css" + +export const Backdrop = ({ onClose }: { onClose: () => void }) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: We don't need keyboard events for backdrop +
{ + if (e.target === e.currentTarget) { + onClose() + } + }} + /> +) diff --git a/app/components/command-k/components/command-k.tsx b/app/components/command-k/components/command-k.tsx new file mode 100644 index 0000000..57b3986 --- /dev/null +++ b/app/components/command-k/components/command-k.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router" +import { Modal } from "~/components/modal" +import type { Version } from "~/utils/version-resolvers" +import { useKeyboardNavigation } from "../hooks/use-keyboard-navigation" +import { useModalState } from "../hooks/use-modal-state" +import { useSearch } from "../hooks/use-search" +import { useSearchHistory } from "../hooks/use-search-history" +import type { HistoryItem, MatchType, SearchResult } from "../search-types" +import { EmptyState } from "./empty-state" +import { ResultsFooter } from "./results-footer" +import { SearchHistory } from "./search-history" +import { SearchInput } from "./search-input" +import { SearchResultRow } from "./search-result" +import { TriggerButton } from "./trigger-button" + +interface CommandPaletteProps { + placeholder?: string + version: Version +} + +export const CommandK = ({ placeholder, version }: CommandPaletteProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const inputRef = useRef(null) + const [query, setQuery] = useState("") + const { isOpen, openModal, closeModal } = useModalState() + const { history, addToHistory, clearHistory, removeFromHistory } = useSearchHistory(version) + const { results, search } = useSearch({ version }) + + const hasQuery = !!query.trim() + const hasResults = !!results.length + const hasHistory = !!history.length + const searchPlaceholder = placeholder ?? t("placeholders.search_documentation") + + const handleClose = () => { + closeModal() + setQuery("") + search("") + } + + const navigateToPage = (id: string) => { + const path = [version, id] + .filter(Boolean) + .map((s) => s.replace(/^\/+|\/+$/g, "")) + .join("/") + + navigate(`/${path}`) + } + + const handleResultSelect = (result: SearchResult) => { + if (!isOpen) return + const rowItem = result.item + const matchType: MatchType = result.refIndex === 0 ? "heading" : "paragraph" + const historyItem = { + ...rowItem, + type: matchType, + highlightedText: result.highlightedText, + } + + addToHistory(historyItem) + navigateToPage(rowItem.id) + handleClose() + } + + const handleHistorySelect = (item: HistoryItem) => { + navigateToPage(item.id) + handleClose() + } + + const handleToggle = () => { + isOpen ? handleClose() : openModal() + } + + const { selectedIndex } = useKeyboardNavigation({ + isOpen, + results, + onSelect: handleResultSelect, + onClose: handleClose, + onToggle: handleToggle, + }) + + if (!isOpen) { + return + } + + const renderBody = () => { + if (hasQuery) { + if (!hasResults) return + + return results.map((result, index) => ( + handleResultSelect(result)} + matchType={result.refIndex === 0 ? "heading" : "paragraph"} + /> + )) + } + + if (hasHistory) { + return ( + + ) + } + + return + } + + return ( + inputRef.current} ariaLabel={searchPlaceholder}> + { + setQuery(val) + search(val.trim()) + }} + placeholder={searchPlaceholder} + /> +
+ {renderBody()} +
+ +
+ ) +} diff --git a/app/components/command-k/components/empty-state.tsx b/app/components/command-k/components/empty-state.tsx new file mode 100644 index 0000000..fc79b79 --- /dev/null +++ b/app/components/command-k/components/empty-state.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next" +import { KeyboardHint } from "./keyboard-hint" +import { ResultsFooterNote } from "./results-footer-note" + +export const EmptyState = ({ query }: { query?: string }) => { + const { t } = useTranslation() + if (query) { + return ( +
+

+ {t("text.no_results_for")} "{query}" +

+

{t("text.adjust_search")}

+
+ ) + } + + return ( +
+

{t("text.start_typing_to_search")}

+
+ + + +
+ +
+ ) +} diff --git a/app/components/command-k/components/keyboard-hint.tsx b/app/components/command-k/components/keyboard-hint.tsx new file mode 100644 index 0000000..7233ae5 --- /dev/null +++ b/app/components/command-k/components/keyboard-hint.tsx @@ -0,0 +1,21 @@ +import { Kbd } from "~/ui/kbd" +import { cn } from "~/utils/css" + +interface KeyboardHintProps { + keys: string | string[] + label: string + className?: string +} + +export const KeyboardHint = ({ keys, label, className }: KeyboardHintProps) => { + const keyArray = Array.isArray(keys) ? keys : [keys] + + return ( +
+ {keyArray.map((key) => ( + {key} + ))} + {label} +
+ ) +} diff --git a/app/components/command-k/components/results-footer-note.tsx b/app/components/command-k/components/results-footer-note.tsx new file mode 100644 index 0000000..1c2dd41 --- /dev/null +++ b/app/components/command-k/components/results-footer-note.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from "react-i18next" + +export const ResultsFooterNote = () => { + const { t } = useTranslation() + return ( + + {t("p.search_by")}{" "} + + + Forge 42 + + + + ) +} diff --git a/app/components/command-k/components/results-footer.tsx b/app/components/command-k/components/results-footer.tsx new file mode 100644 index 0000000..3915e87 --- /dev/null +++ b/app/components/command-k/components/results-footer.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" +import { cn } from "~/utils/css" +import { KeyboardHint } from "./keyboard-hint" +import { ResultsFooterNote } from "./results-footer-note" + +export const ResultsFooter = ({ + resultsCount, + query, +}: { + resultsCount: number + query: string +}) => { + const { t } = useTranslation() + if (!query || resultsCount === 0) return null + + return ( +
+
+ {t("text.result", { count: resultsCount })} +
+ + + +
+
+
+ ) +} diff --git a/app/components/command-k/components/search-history.tsx b/app/components/command-k/components/search-history.tsx new file mode 100644 index 0000000..4deb0e1 --- /dev/null +++ b/app/components/command-k/components/search-history.tsx @@ -0,0 +1,122 @@ +import { useTranslation } from "react-i18next" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import type { HistoryItem } from "../search-types" +import { SearchResultRow } from "./search-result" +interface SearchHistoryProps { + history: HistoryItem[] + onSelect: (item: HistoryItem) => void + onRemove: (id: string) => void + onClear: () => void +} + +const SearchHistoryHeader = ({ onClear }: Pick) => { + const { t } = useTranslation() + return ( +
+
+ + {t("text.recent_searches")} +
+ +
+ ) +} + +const ClearHistoryButton = ({ onClear }: Pick) => { + const { t } = useTranslation() + return ( + + ) +} + +const RemoveItemButton = ({ + onRemove, + id, +}: { + onRemove: Pick["onRemove"] + id: string +}) => ( + +) + +const HistoryItemRow = ({ + item, + index, + onSelect, + onRemove, +}: { + item: HistoryItem + index: number + onSelect: Pick["onSelect"] + onRemove: Pick["onRemove"] +}) => ( +
+ onSelect(item)} + matchType={item.type ?? "heading"} + /> + +
+) + +const HistoryItemsList = ({ + history, + onSelect, + onRemove, +}: { + history: HistoryItem[] + onSelect: Pick["onSelect"] + onRemove: Pick["onRemove"] +}) => ( +
+ {history.map((item, index) => ( + + ))} +
+) + +export const SearchHistory = ({ history, onSelect, onRemove, onClear }: SearchHistoryProps) => { + if (history.length === 0) return null + return ( +
+ + +
+ ) +} diff --git a/app/components/command-k/components/search-input.tsx b/app/components/command-k/components/search-input.tsx new file mode 100644 index 0000000..7ccf51f --- /dev/null +++ b/app/components/command-k/components/search-input.tsx @@ -0,0 +1,47 @@ +import type { Ref } from "react" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +interface SearchInputProps { + value: string + onChange: (value: string) => void + placeholder: string + ref?: Ref +} + +export function SearchInput({ value, onChange, placeholder, ref }: SearchInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + "flex-1 bg-transparent text-lg leading-6 outline-none", + "text-[var(--color-input-text)] placeholder-[var(--color-input-placeholder)]" + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> +
+ + ESC + +
+
+ ) +} diff --git a/app/components/command-k/components/search-result.tsx b/app/components/command-k/components/search-result.tsx new file mode 100644 index 0000000..79b5749 --- /dev/null +++ b/app/components/command-k/components/search-result.tsx @@ -0,0 +1,86 @@ +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import type { MatchType, SearchRecord } from "../search-types" + +interface SearchResultProps { + item: SearchRecord + highlightedText: string + isSelected: boolean + onClick: () => void + matchType: MatchType +} + +const ResultIcon = ({ + isSelected, + matchType, +}: { + isSelected: boolean + matchType: MatchType +}) => { + const iconName = matchType === "heading" ? "Hash" : "Pilcrow" + + return ( +
+ +
+ ) +} + +const ResultTitle = ({ + title, + highlightedText, + isSelected, +}: { + title: string + highlightedText: string + isSelected: boolean +}) => ( +
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: rendering text */} + +
+) + +const ResultMetadata = ({ item, matchType }: Pick) => ( +
+ {item.title} + {matchType === "paragraph" && item.subtitle ? > {item.subtitle} : null} +
+) + +const ResultContent = ({ item, highlightedText, isSelected, matchType }: Omit) => ( +
+ + +
+) + +const useButtonStyles = (isSelected: boolean) => + cn( + "flex w-full items-start gap-3 border-r-2 px-4 py-3 text-left transition-all duration-150", + "hover:bg-[var(--color-result-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--color-trigger-focus-ring)]", + isSelected + ? "border-[var(--color-result-selected-border)] bg-[var(--color-result-selected)] shadow-sm" + : "border-transparent" + ) + +export const SearchResultRow = ({ item, highlightedText, isSelected, onClick, matchType }: SearchResultProps) => { + const buttonStyles = useButtonStyles(isSelected) + + return ( + + ) +} diff --git a/app/components/command-k/components/trigger-button.tsx b/app/components/command-k/components/trigger-button.tsx new file mode 100644 index 0000000..3c243e2 --- /dev/null +++ b/app/components/command-k/components/trigger-button.tsx @@ -0,0 +1,45 @@ +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +export const TriggerButton = ({ + onOpen, + placeholder, +}: { + onOpen: () => void + placeholder: string +}) => ( + +) diff --git a/app/components/command-k/create-search-index.ts b/app/components/command-k/create-search-index.ts new file mode 100644 index 0000000..368e519 --- /dev/null +++ b/app/components/command-k/create-search-index.ts @@ -0,0 +1,128 @@ +import type { Page } from "content-collections" +import slug from "slug" +import { getPageSlug } from "~/utils/get-page-slug" + +function cleanParagraph(raw: string) { + return ( + raw + // strip inline code, bold, italics + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + // strip markdown links [text](url) + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + // strip mdx attributes { ... } inline + .replace(/\{[^}]*\}/g, "") + // list bullets / ordered list markers at line start + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // collapse whitespace + .replace(/\n{2,}/g, "\n") + .replace(/[ \t]+/g, " ") + .trim() + ) +} + +function stripCodeFences(src: string) { + return src.replace(/```[\s\S]*?```/g, "") +} + +function splitIntoParagraphs(src: string) { + return src + .split(/\n\s*\n/g) + .map(cleanParagraph) + .filter((p) => p.length > 0) +} + +const extractHeadingData = (match: RegExpMatchArray) => { + const [fullMatch, hashes, text] = match + return { + level: hashes.length, + text, + index: match.index || 0, + length: fullMatch.length, + } +} + +function extractHeadingSections(rawMdx: string) { + const src = stripCodeFences(rawMdx) + const headingRegex = /^(#{1,6})\s+(.+?)\s*$/gm + const matches = Array.from(src.matchAll(headingRegex), extractHeadingData) + + const usedAnchors = new Set() + + const createUniqueAnchor = (baseAnchor: string) => { + let unique = baseAnchor + let n = 2 + while (usedAnchors.has(unique)) { + unique = `${baseAnchor}-${n++}` + } + usedAnchors.add(unique) + return unique + } + + const cleanHeadingText = (text: string) => + text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\{[^}]*\}/g, "") + .trim() + + if (matches.length === 0) { + const paragraphs = splitIntoParagraphs(src) + return paragraphs.length ? [{ heading: "_intro", anchor: "_intro", paragraphs }] : [] + } + + const sections = [] + + // we are adding intro section if content exists before first heading + const introBlock = src.slice(0, matches[0].index).trim() + if (introBlock) { + const introParas = splitIntoParagraphs(introBlock) + if (introParas.length) { + sections.push({ heading: "_intro", anchor: "_intro", paragraphs: introParas }) + } + } + + matches.forEach((match, i) => { + const nextMatch = matches[i + 1] + const block = src.slice(match.index + match.length, nextMatch?.index).trim() + + const rawHeading = cleanHeadingText(match.text) + const baseAnchor = slug(rawHeading) || "_section" + const anchor = createUniqueAnchor(baseAnchor) + const paragraphs = splitIntoParagraphs(block) + + sections.push({ + heading: rawHeading, + anchor, + paragraphs, + }) + }) + + return sections +} + +export function createSearchIndex(pages: Page[]) { + return pages + .filter((page) => page._meta.fileName !== "_index.mdx" && page.slug !== "_index") + .flatMap((page) => { + const pageSlug = getPageSlug(page) + const pageUrl = pageSlug.startsWith("/") ? pageSlug : `/${pageSlug}` + const sections = extractHeadingSections(page.rawMdx) + + return sections.map((section) => { + const heading = section.heading === "_intro" ? page.title : section.heading + + return { + id: `${pageUrl}#${section.anchor}`, + title: page.title, + subtitle: heading, + paragraphs: [heading, ...section.paragraphs], + } + }) + }) +} diff --git a/app/components/command-k/hooks/use-fuzzy-search.ts b/app/components/command-k/hooks/use-fuzzy-search.ts new file mode 100644 index 0000000..a7b3f6c --- /dev/null +++ b/app/components/command-k/hooks/use-fuzzy-search.ts @@ -0,0 +1,76 @@ +import type { FuzzySearchOptions, SearchRecord, SearchResult } from "../search-types" + +const DEFAULTS = { + threshold: 0.8, // results must score ≥ 0.8 to be considered relevant + minMatchCharLength: 2, // queries shorter than 2 chars are ignored +} + +const clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n) +const toSearchable = (s: string) => s.toLowerCase().trim() +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const scoreMatchQuality = (query: string, text: string) => { + const q = toSearchable(query) + const t = toSearchable(text) + if (q.length < DEFAULTS.minMatchCharLength) return 0 // ignore very short queries + if (t === q) return 1 // exact match -> strongest + if (t.startsWith(q)) return 0.95 // starts with query -> very strong + if (t.includes(q)) return 0.85 // contains query -> weaker + return 0 // no match +} + +const highlightSnippet = (text: string, query: string, maxLen = 120) => { + const trimmed = text.trim() + const q = toSearchable(query) + const idx = trimmed.toLowerCase().indexOf(q) + + if (idx === -1) { + // if no match, just return truncated text + return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}...` : trimmed + } + + const half = Math.floor(maxLen / 2) + const start = Math.max(0, idx - half) + const end = Math.min(trimmed.length, idx + q.length + half) + const snippet = trimmed.slice(start, end) + + const safe = escapeRegExp(q) + const marked = snippet.replace( + new RegExp(`(${safe})`, "gi"), + `$1` + ) + + return `${start > 0 ? "..." : ""}${marked}${end < trimmed.length ? "..." : ""}` +} + +export function useFuzzySearch(items: SearchRecord[], query: string, options?: FuzzySearchOptions) { + const threshold = clamp(options?.threshold ?? DEFAULTS.threshold, 0, 1) + const minLen = Math.max(0, options?.minMatchCharLength ?? DEFAULTS.minMatchCharLength) + + const raw = query?.trim() + if (!raw || raw.length < minLen) return [] + + const results: SearchResult[] = [] + + for (const item of items) { + const paragraphs: ReadonlyArray = item.paragraphs ?? [] + + paragraphs.forEach((paragraph, paragraphIndex) => { + if (!paragraph) return + + const score = scoreMatchQuality(raw, paragraph) + + if (score >= threshold) { + results.push({ + item, + score: clamp(score, 0, 1), + matchedText: paragraph, + highlightedText: highlightSnippet(paragraph, raw), + refIndex: paragraphIndex, + }) + } + }) + } + + return results.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.refIndex - b.refIndex)) +} diff --git a/app/components/command-k/hooks/use-keyboard-navigation.ts b/app/components/command-k/hooks/use-keyboard-navigation.ts new file mode 100644 index 0000000..fb4d480 --- /dev/null +++ b/app/components/command-k/hooks/use-keyboard-navigation.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react" +import type { SearchResult } from "../search-types" + +const KEYBOARD_SHORTCUTS = { + TOGGLE: "k", + ESCAPE: "Escape", + ARROW_DOWN: "ArrowDown", + ARROW_UP: "ArrowUp", + ENTER: "Enter", + TAB: "Tab", +} as const + +interface UseKeyboardNavigationProps { + isOpen: boolean + results: SearchResult[] + onSelect: (result: SearchResult) => void + onClose: () => void + onToggle: () => void +} + +export const useKeyboardNavigation = ({ isOpen, results, onSelect, onClose, onToggle }: UseKeyboardNavigationProps) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + setSelectedIndex(0) + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === KEYBOARD_SHORTCUTS.TOGGLE) { + e.preventDefault() + onToggle() + return + } + + if (e.key === KEYBOARD_SHORTCUTS.ESCAPE) { + onClose() + return + } + + if (!isOpen) return + + switch (e.key) { + case KEYBOARD_SHORTCUTS.ARROW_DOWN: + e.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + + case KEYBOARD_SHORTCUTS.ARROW_UP: + e.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + break + + case KEYBOARD_SHORTCUTS.ENTER: + e.preventDefault() + if (results[selectedIndex]) { + onSelect(results[selectedIndex]) + } + break + + case KEYBOARD_SHORTCUTS.TAB: + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % results.length) + break + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, results, selectedIndex, onSelect, onClose, onToggle]) + + return { selectedIndex } +} diff --git a/app/components/command-k/hooks/use-modal-state.ts b/app/components/command-k/hooks/use-modal-state.ts new file mode 100644 index 0000000..68429e0 --- /dev/null +++ b/app/components/command-k/hooks/use-modal-state.ts @@ -0,0 +1,16 @@ +import { useState } from "react" + +export const useModalState = (controlledIsOpen?: boolean, onOpenChange?: (open: boolean) => void) => { + const [internalIsOpen, setInternalIsOpen] = useState(false) + + const isOpen = controlledIsOpen ?? internalIsOpen + + const setIsOpen = (open: boolean) => { + onOpenChange ? onOpenChange(open) : setInternalIsOpen(open) + } + + const openModal = () => setIsOpen(true) + const closeModal = () => setIsOpen(false) + + return { isOpen, openModal, closeModal } +} diff --git a/app/components/command-k/hooks/use-search-history.ts b/app/components/command-k/hooks/use-search-history.ts new file mode 100644 index 0000000..092a639 --- /dev/null +++ b/app/components/command-k/hooks/use-search-history.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react" +import { COMMAND_K_SEARCH_HISTORY, getStorageItem, removeStorageItem, setStorageItem } from "~/utils/local-storage" +import { normalizeVersion } from "~/utils/version-resolvers" +import type { HistoryItem } from "../search-types" + +const MAX_HISTORY_ITEMS = 10 + +function keyFor(version: string) { + const { version: v } = normalizeVersion(version) + return `${COMMAND_K_SEARCH_HISTORY}-${v}` +} + +export const useSearchHistory = (version: string) => { + const storageKey = keyFor(version) + const [history, setHistory] = useState([]) + + useEffect(() => { + try { + const stored = getStorageItem(storageKey) + if (!stored) { + setHistory([]) + return + } + const parsed = JSON.parse(stored) + setHistory(Array.isArray(parsed) ? parsed : []) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.warn("Failed to load search history:", err) + setHistory([]) + } + }, [storageKey]) + + useEffect(() => { + try { + setStorageItem(storageKey, JSON.stringify(history)) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.warn("Failed to save search history:", err) + } + }, [history, storageKey]) + + const addToHistory = (item: HistoryItem) => { + setHistory((prev) => { + const idx = prev.findIndex((h) => h.id === item.id) + if (idx >= 0) { + const existing = prev[idx] + const updated = { + ...existing, + type: item.type ?? existing.type, + title: item.title ?? existing.title, + subtitle: item.subtitle ?? existing.subtitle, + paragraphs: item.paragraphs ?? existing.paragraphs, + highlightedText: item.highlightedText ?? existing.highlightedText, + } + return [updated, ...prev.slice(0, idx), ...prev.slice(idx + 1)].slice(0, MAX_HISTORY_ITEMS) + } + return [item, ...prev].slice(0, MAX_HISTORY_ITEMS) + }) + } + + const clearHistory = () => { + setHistory([]) + removeStorageItem(storageKey) + } + + const removeFromHistory = (itemId: string) => { + setHistory((prev) => prev.filter((item) => item.id !== itemId)) + } + + return { history, addToHistory, clearHistory, removeFromHistory } +} diff --git a/app/components/command-k/hooks/use-search.ts b/app/components/command-k/hooks/use-search.ts new file mode 100644 index 0000000..cd06c74 --- /dev/null +++ b/app/components/command-k/hooks/use-search.ts @@ -0,0 +1,56 @@ +import { useState } from "react" +import { useFetcher } from "react-router" +import z from "zod" +import type { Version } from "~/utils/version-resolvers" +import { versions } from "~/utils/versions" +import type { SearchResult } from "../search-types" + +export const commandKSearchParamsSchema = z.object({ + query: z.string(), + version: z.enum(versions), +}) + +export type CommandKSearchParams = z.infer + +function createCommandKSearchParams(params: Record) { + const result = commandKSearchParamsSchema.safeParse(params) + if (!result.success) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.error("Invalid parameters:", result.error) + return { params: null } + } + + return { params: new URLSearchParams(result.data) } +} + +export function useSearch({ version }: { version: Version }) { + const fetcher = useFetcher<{ results: SearchResult[] }>() + const [query, setQuery] = useState("") + //we will show results as soon as we have a non-empty query + //this does not debounce or wait for fetcher.state === "idle". + const results = query.trim() ? (fetcher.data?.results ?? []) : [] + + function search(q: string) { + const trimmed = q.trim() + + if (!trimmed) { + setQuery("") + return + } + + setQuery(trimmed) + const { params } = createCommandKSearchParams({ query: trimmed, version }) + if (!params) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.error("Failed to create search parameters.") + return + } + + fetcher.load(`/search?${params.toString()}`) + } + + return { + results, + search, + } +} diff --git a/app/components/command-k/search-types.ts b/app/components/command-k/search-types.ts new file mode 100644 index 0000000..f0bfcdd --- /dev/null +++ b/app/components/command-k/search-types.ts @@ -0,0 +1,26 @@ +export interface SearchRecord { + id: string //e.g "/configuration/editor#name" where name is the heading inside of the editor page under the configuration section + title: string // page title + subtitle: string // title of the "sections" inside the page + paragraphs: string[] // for that id (section of the current page) get all paragraphs as an array of strings +} + +export interface SearchResult { + item: SearchRecord + score: number + matchedText: string + highlightedText: string + refIndex: number // 0 if heading, >0 if actual paragraph +} + +export interface FuzzySearchOptions { + threshold: number + minMatchCharLength: number +} + +export type MatchType = "heading" | "paragraph" + +export interface HistoryItem extends SearchRecord { + type?: MatchType + highlightedText?: string +} diff --git a/app/components/modal.tsx b/app/components/modal.tsx new file mode 100644 index 0000000..f68b779 --- /dev/null +++ b/app/components/modal.tsx @@ -0,0 +1,87 @@ +import { type ReactNode, useEffect, useRef } from "react" +import { useScrollLock } from "~/hooks/use-scroll-lock" +import { cn } from "~/utils/css" +import { Backdrop } from "./backdrop" + +interface ModalProps { + isOpen: boolean + onClose: () => void + children: ReactNode + className?: string + getInitialFocus?: () => HTMLElement | null + restoreFocus?: boolean + ariaLabel?: string +} + +export const Modal = ({ + isOpen, + onClose, + children, + className, + getInitialFocus, + restoreFocus = true, + ariaLabel, +}: ModalProps) => { + const modalRef = useRef(null) + const previouslyFocusedRef = useRef(null) + + useScrollLock(isOpen) + + useEffect(() => { + if (!isOpen) return + previouslyFocusedRef.current = document.activeElement as HTMLElement | null + return () => { + if (restoreFocus) previouslyFocusedRef.current?.focus?.() + } + }, [isOpen, restoreFocus]) + + useEffect(() => { + if (!isOpen) return + const id = requestAnimationFrame(() => { + const candidate = getInitialFocus?.() + if (candidate) { + candidate.focus() + return + } + const root = modalRef.current + if (!root) return + const firstFocusable = root.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + firstFocusable?.focus() + }) + return () => cancelAnimationFrame(id) + }, [isOpen, getInitialFocus]) + + useEffect(() => { + if (!isOpen) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( +
+ +
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {children} +
+
+
+ ) +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx index f09a7b9..f0cf59e 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -8,10 +8,13 @@ import { type AppLoadContext, type EntryContext, ServerRouter } from "react-rout import i18n from "./localization/i18n" // your i18n configuration file import i18nextOpts from "./localization/i18n.server" import { resources } from "./localization/resource" +import { preloadSearchIndexes } from "./server/search-index" // Reject all pending promises from handler functions after 10 seconds export const streamTimeout = 10000 +await preloadSearchIndexes() + export default async function handleRequest( request: Request, responseStatusCode: number, diff --git a/app/hooks/use-scroll-lock.ts b/app/hooks/use-scroll-lock.ts new file mode 100644 index 0000000..51da69e --- /dev/null +++ b/app/hooks/use-scroll-lock.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef } from "react" + +/** + * Locks the body scroll when `isActive` is true. + * Uses position: fixed + scroll restoration (avoids layout shift). + */ +export function useScrollLock(isActive: boolean) { + const scrollYRef = useRef(0) + + useEffect(() => { + if (!isActive) return + + scrollYRef.current = window.scrollY + const body = document.body + const html = document.documentElement + + const prevBodyStyle = { + position: body.style.position, + top: body.style.top, + width: body.style.width, + } + const prevHtmlOverscroll = html.style.overscrollBehavior + + body.style.position = "fixed" + body.style.top = `-${scrollYRef.current}px` + body.style.width = "100%" + + html.style.overscrollBehavior = "contain" + + return () => { + body.style.position = prevBodyStyle.position + body.style.top = prevBodyStyle.top + body.style.width = prevBodyStyle.width + html.style.overscrollBehavior = prevHtmlOverscroll + + window.scrollTo(0, scrollYRef.current) + } + }, [isActive]) +} diff --git a/app/routes.ts b/app/routes.ts index 2545c85..0f336b3 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, layout, route } from "@react-router/dev/routes export default [ index("routes/index.tsx"), + route("search", "routes/search.ts"), layout("routes/documentation-layout.tsx", [ route(":version?/home", "routes/documentation-homepage.tsx"), route(":version/:section/:subsection?/:filename", "routes/documentation-page.tsx"), diff --git a/app/routes/documentation-layout.tsx b/app/routes/documentation-layout.tsx index e48bbb8..5a90979 100644 --- a/app/routes/documentation-layout.tsx +++ b/app/routes/documentation-layout.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router" +import { CommandK } from "~/components/command-k/components/command-k" import { Header } from "~/components/header" import { Logo } from "~/components/logo" import { Sidebar } from "~/components/sidebar/sidebar" @@ -14,8 +15,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { return { sidebarTree, version } } export default function DocumentationLayout({ loaderData }: Route.ComponentProps) { - const { sidebarTree } = loaderData - + const { sidebarTree, version } = loaderData return (
@@ -26,7 +26,10 @@ export default function DocumentationLayout({ loaderData }: Route.ComponentProps
- +
+ + +
diff --git a/app/routes/search.ts b/app/routes/search.ts new file mode 100644 index 0000000..20daec4 --- /dev/null +++ b/app/routes/search.ts @@ -0,0 +1,27 @@ +import { commandKSearchParamsSchema } from "~/components/command-k/hooks/use-search" +import { fuzzySearch } from "~/server/search-index" +import { parseSearchParams } from "~/utils/parse-search-params" +import type { Route } from "./+types/search" + +export async function loader({ request }: Route.LoaderArgs) { + const { params } = parseSearchParams(request, commandKSearchParamsSchema) + if (!params) { + throw new Response("Bad Request", { status: 400 }) + } + + const { query, version } = params + if (!query) { + return { results: [] } + } + + try { + const results = await fuzzySearch({ query: query.trim(), version }) + return { + results, + } + } catch (error) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.error("Search error:", error) + return { results: [] } + } +} diff --git a/app/server/search-index.ts b/app/server/search-index.ts new file mode 100644 index 0000000..8390151 --- /dev/null +++ b/app/server/search-index.ts @@ -0,0 +1,35 @@ +import { createSearchIndex } from "~/components/command-k/create-search-index" +import { useFuzzySearch } from "~/components/command-k/hooks/use-fuzzy-search" +import type { CommandKSearchParams } from "~/components/command-k/hooks/use-search" +import type { SearchRecord } from "~/components/command-k/search-types" +import { loadContentCollections } from "~/utils/load-content-collections" +import type { Version } from "~/utils/version-resolvers" +import { versions } from "~/utils/versions" + +const searchIndexes: Map = new Map() + +export async function preloadSearchIndexes() { + await Promise.all( + versions.map(async (version) => { + if (!searchIndexes.has(version)) { + const { allPages } = await loadContentCollections(version) + const searchIndex = createSearchIndex(allPages) + searchIndexes.set(version, searchIndex) + } + }) + ) +} + +async function getSearchIndex(version: Version) { + const index = searchIndexes.get(version) + if (!index) { + throw new Error(`Search index for version "${version}" could not be retrieved.`) + } + + return index +} + +export async function fuzzySearch({ query, version }: CommandKSearchParams) { + const searchIndex = await getSearchIndex(version) + return useFuzzySearch(searchIndex, query) +} diff --git a/app/tailwind.css b/app/tailwind.css index 17caab8..72c5bea 100644 --- a/app/tailwind.css +++ b/app/tailwind.css @@ -57,6 +57,69 @@ --color-warning-border: #fde68a; --color-warning-text: #92400e; --color-warning-icon: #f59e0b; + + --color-modal-backdrop: rgba(17, 24, 39, 0.5); + --color-modal-bg: #ffffff; + --color-modal-border: #e5e7eb; + --color-modal-shadow: rgba(0, 0, 0, 0.25); + + --color-input-bg: rgba(249, 250, 251, 0.5); + --color-input-border: #e5e7eb; + --color-input-text: #111827; + --color-input-placeholder: #6b7280; + --color-input-icon: #9ca3af; + + --color-result-hover: #f9fafb; + --color-result-selected: #eff6ff; + --color-result-selected-border: #3b82f6; + --color-result-selected-text: #1e3a8a; + --color-result-text: #111827; + --color-result-meta: #6b7280; + --color-result-icon: #9ca3af; + --color-result-icon-selected: #3b82f6; + --color-result-arrow: #d1d5db; + + --color-breadcrumb-bg: #f3f4f6; + --color-breadcrumb-text: #6b7280; + + --color-footer-bg: #f9fafb; + --color-footer-border: #e5e7eb; + --color-footer-text: #6b7280; + --color-footer-kbd-bg: #ffffff; + --color-footer-kbd-border: #e5e7eb; + + --color-history-header-bg: rgba(249, 250, 251, 0.5); + --color-history-header-border: #e5e7eb; + --color-history-header-text: #374151; + --color-history-clear-hover-bg: #fef2f2; + --color-history-clear-hover-text: #dc2626; + --color-history-remove-bg: #ffffff; + --color-history-remove-border: #e5e7eb; + --color-history-remove-text: #9ca3af; + --color-history-remove-hover-border: #fecaca; + --color-history-remove-hover-text: #ef4444; + + --color-empty-icon-bg: #f3f4f6; + --color-empty-icon: #9ca3af; + --color-empty-text: #6b7280; + --color-empty-text-muted: #9ca3af; + --color-empty-icon-accent: #3b82f6; + + --color-kbd-bg: #f3f4f6; + --color-kbd-border: #d1d5db; + --color-kbd-text: #6b7280; + + --color-trigger-bg: #ffffff; + --color-trigger-border: #e5e7eb; + --color-trigger-text: #6b7280; + --color-trigger-hover-bg: #f9fafb; + --color-trigger-hover-border: #d1d5db; + --color-trigger-hover-text: #4b5563; + --color-trigger-focus-border: #93c5fd; + --color-trigger-focus-ring: rgba(59, 130, 246, 0.2); + + --color-highlight-bg: #fef3c7; + --color-highlight-text: #92400e; } [data-theme="dark"] { @@ -101,5 +164,68 @@ --color-warning-border: rgba(245, 158, 11, 0.2); --color-warning-text: #fbbf24; --color-warning-icon: #f59e0b; + + --color-modal-backdrop: rgb(15, 15, 15, 0.8); + --color-modal-bg: rgb(15, 15, 15); + --color-modal-border: #374151; + --color-modal-shadow: rgba(0, 0, 0, 0.5); + + --color-input-bg: rgba(31, 41, 55, 0.5); + --color-input-border: #374151; + --color-input-text: #f9fafb; + --color-input-placeholder: #9ca3af; + --color-input-icon: #6b7280; + + --color-result-hover: rgba(31, 41, 55, 0.5); + --color-result-selected: rgba(59, 130, 246, 0.2); + --color-result-selected-border: #3b82f6; + --color-result-selected-text: #93c5fd; + --color-result-text: #f9fafb; + --color-result-meta: #9ca3af; + --color-result-icon: #6b7280; + --color-result-icon-selected: #60a5fa; + --color-result-arrow: #4b5563; + + --color-breadcrumb-bg: #0f0f0f; + --color-breadcrumb-text: #6b7280; + + --color-footer-bg: #0f0f0f; + --color-footer-border: #374151; + --color-footer-text: #9ca3af; + --color-footer-kbd-bg: #374151; + --color-footer-kbd-border: #4b5563; + + --color-history-header-bg: rgba(31, 41, 55, 0.5); + --color-history-header-border: #374151; + --color-history-header-text: #d1d5db; + --color-history-clear-hover-bg: rgba(185, 28, 28, 0.2); + --color-history-clear-hover-text: #f87171; + --color-history-remove-bg: #0f0f0f; + --color-history-remove-border: #374151; + --color-history-remove-text: #6b7280; + --color-history-remove-hover-border: rgba(185, 28, 28, 0.8); + --color-history-remove-hover-text: #f87171; + + --color-empty-icon-bg: #0f0f0f; + --color-empty-icon: #6b7280; + --color-empty-text: #9ca3af; + --color-empty-text-muted: #6b7280; + --color-empty-icon-accent: #60a5fa; + + --color-kbd-bg: #0f0f0f; + --color-kbd-border: #4b5563; + --color-kbd-text: #9ca3af; + + --color-trigger-bg: #0f0f0f; + --color-trigger-border: #374151; + --color-trigger-text: #9ca3af; + --color-trigger-hover-bg: #374151; + --color-trigger-hover-border: #4b5563; + --color-trigger-hover-text: #d1d5db; + --color-trigger-focus-border: #60a5fa; + --color-trigger-focus-ring: rgba(96, 165, 250, 0.2); + + --color-highlight-bg: rgba(245, 158, 11, 0.5); + --color-highlight-text: #fbbf24; } } diff --git a/app/ui/breadcrumbs.tsx b/app/ui/breadcrumbs.tsx index e4e806d..1be92e2 100644 --- a/app/ui/breadcrumbs.tsx +++ b/app/ui/breadcrumbs.tsx @@ -30,7 +30,9 @@ export const BreadcrumbItem = ({ children, href, isActive = false, className }: ) } - return {children} + return ( + {children} + ) } export const Breadcrumbs = ({ children, className }: BreadcrumbsProps) => { diff --git a/app/ui/icon/icons/icon.svg b/app/ui/icon/icons/icon.svg index 7e6482c..15776ba 100644 --- a/app/ui/icon/icons/icon.svg +++ b/app/ui/icon/icons/icon.svg @@ -4,14 +4,19 @@ + + + + + diff --git a/app/ui/icon/icons/types.ts b/app/ui/icon/icons/types.ts index 148a4f5..6b66d8d 100644 --- a/app/ui/icon/icons/types.ts +++ b/app/ui/icon/icons/types.ts @@ -4,14 +4,19 @@ export const iconNames = [ "Zap", "X", "TriangleAlert", + "Trash2", "Sun", "SunMoon", + "Search", "Rocket", + "Pilcrow", "Moon", "Menu", "Info", + "Hash", "Github", "Ghost", + "Clock", "ClipboardCopy", "ClipboardCheck", "ChevronRight", diff --git a/app/ui/kbd.tsx b/app/ui/kbd.tsx new file mode 100644 index 0000000..5116532 --- /dev/null +++ b/app/ui/kbd.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react" +import { cn } from "~/utils/css" + +export function Kbd({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( + + {children} + + ) +} diff --git a/app/utils/get-page-slug.tsx b/app/utils/get-page-slug.tsx new file mode 100644 index 0000000..6e50da8 --- /dev/null +++ b/app/utils/get-page-slug.tsx @@ -0,0 +1,5 @@ +import type { Page } from "content-collections" + +export function getPageSlug(page: Page) { + return page._meta.path === "_index" ? "/" : page.slug +} diff --git a/app/utils/local-storage.ts b/app/utils/local-storage.ts index 44f649b..ea72753 100644 --- a/app/utils/local-storage.ts +++ b/app/utils/local-storage.ts @@ -7,4 +7,6 @@ export const setStorageItem = (key: string, value: string) => { } } +export const removeStorageItem = (key: string) => localStorage.removeItem(key) export const THEME = "theme" +export const COMMAND_K_SEARCH_HISTORY = "command-k-search-history" diff --git a/app/utils/parse-search-params.ts b/app/utils/parse-search-params.ts new file mode 100644 index 0000000..f1d3bb8 --- /dev/null +++ b/app/utils/parse-search-params.ts @@ -0,0 +1,15 @@ +import type z from "zod" + +export function parseSearchParams(request: Request, schema: T) { + const url = new URL(request.url) + const params = Object.fromEntries(url.searchParams.entries()) + const result = schema.safeParse(params) + + if (!result.success) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.error("Invalid query parameters:", result.error) + return { params: null } + } + + return { params: result.data } +} diff --git a/content-collections.ts b/content-collections.ts index 15d3f1e..f51d7e1 100644 --- a/content-collections.ts +++ b/content-collections.ts @@ -73,6 +73,7 @@ const page = defineCollection({ const content = await compileMDX(context, document, { rehypePlugins: [rehypeSlug], }) + // rawMdx is the content without the frontmatter, used to read headings from the mdx file and create a content tree for the table of content component const rawMdx = document.content.replace(/^---\s*[\r\n](.*?|\r|\n)---/, "").trim() diff --git a/resources/icons/clock.svg b/resources/icons/clock.svg new file mode 100644 index 0000000..98c2fac --- /dev/null +++ b/resources/icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/hash.svg b/resources/icons/hash.svg new file mode 100644 index 0000000..4155d9d --- /dev/null +++ b/resources/icons/hash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/pilcrow.svg b/resources/icons/pilcrow.svg new file mode 100644 index 0000000..a56bd2a --- /dev/null +++ b/resources/icons/pilcrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/search.svg b/resources/icons/search.svg new file mode 100644 index 0000000..2fa5416 --- /dev/null +++ b/resources/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/trash-2.svg b/resources/icons/trash-2.svg new file mode 100644 index 0000000..d82ac24 --- /dev/null +++ b/resources/icons/trash-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/locales/bs/common.json b/resources/locales/bs/common.json index 981842f..71d9587 100644 --- a/resources/locales/bs/common.json +++ b/resources/locales/bs/common.json @@ -31,16 +31,36 @@ "p": { "last_update": "Posljednja izmjena:", "version": "Verzija", - "all_rights_reserved": "Sva prava zadržana." + "all_rights_reserved": "Sva prava zadržana.", + "search_by": "Pretraga od" }, "buttons": { "copy": "Kopiraj", "copied": "Kopirano", "home": "Nazad na početnu", - "back": "Idi nazad" + "back": "Idi nazad", + "clear": "Obriši" }, "titles": { "good_to_know": "Dobro je znati", "warning": "Upozorenje" + }, + "text": { + "result_one": "{{count}} rezultat", + "result_other": "{{count}} rezultata", + "adjust_search": "Probajte prilagoditi pojmove za pretragu ili provjerite greške u kucanju", + "no_results_for": "Nema rezultata za", + "start_typing_to_search": "Počnite kucati za pretragu...", + "recent_searches": "Nedavne pretrage" + }, + "controls": { + "navigate": "Navigiraj", + "open": "Otvori", + "tab": "Tab", + "select": "Odaberi", + "cycle": "Kruži" + }, + "placeholders": { + "search_documentation": "Pretraži dokumentaciju..." } } diff --git a/resources/locales/en/common.json b/resources/locales/en/common.json index e43ff97..6336df5 100644 --- a/resources/locales/en/common.json +++ b/resources/locales/en/common.json @@ -31,16 +31,36 @@ "p": { "last_update": "Last updated: ", "version": "Version", - "all_rights_reserved": "All rights reserved." + "all_rights_reserved": "All rights reserved.", + "search_by": "Search by" }, "buttons": { "copy": "Copy", "copied": "Copied", "home": "Back to home", - "back": "Go back" + "back": "Go back", + "clear": "Clear" }, "titles": { "good_to_know": "Good to know", "warning": "Warning" + }, + "text": { + "result_one": "{{count}} result", + "result_other": "{{count}} results", + "adjust_search": "Try adjusting your search terms or check for typos", + "no_results_for": "No results found for", + "start_typing_to_search": "Start typing to search...", + "recent_searches": "Recent searches" + }, + "controls": { + "navigate": "Navigate", + "open": "Open", + "tab": "Tab", + "select": "Select", + "cycle": "Cycle" + }, + "placeholders": { + "search_documentation": "Search documentation..." } }