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..."
}
}