Add docs search to the sidebar

This commit is contained in:
Eric
2026-10-15 13:52:54 +02:00
parent d672fc43db
commit fb2377de17
9 changed files with 301 additions and 33 deletions
+14
View File
@@ -68,6 +68,12 @@ export default defineConfig({
new URL('./theme/components/overrides/VPCarbonAds.vue', import.meta.url),
),
},
{
find: /^.*\/VPNavBarSearch\.vue$/,
replacement: fileURLToPath(
new URL('./theme/components/overrides/VPNavBarSearch.vue', import.meta.url),
),
},
{
find: '~/.vitepress',
replacement: fileURLToPath(new URL('./', import.meta.url)),
@@ -125,6 +131,14 @@ export default defineConfig({
editLink: {
pattern: 'https://github.com/lucide-icons/lucide/edit/main/docs/:path',
},
search: {
provider: 'algolia',
options: {
appId: 'R7KKMCPXOB',
apiKey: 'dd3101054267d35fb28f49c42715f1aa',
indexName: 'lucide'
}
},
carbonAds: {
code: 'CWYIC53U',
placement: 'lucidedev',
+41 -6
View File
@@ -1,7 +1,19 @@
import { PageData } from 'vitepress';
/**
* Mirrors the notices in the repo's LICENSE file, which is bumped by hand — keep the
* year here in step with it.
*/
const LUCIDE_COPYRIGHT = 'Copyright (c) 2026 Lucide Icons and Contributors';
const FEATHER_COPYRIGHT = 'Copyright (c) 2013-present Cole Bemis';
const LICENSE_URL = 'https://lucide.dev/license';
export default async function getStructuredData(iconName: string, pageData: PageData) {
const url = `https://lucide.dev/icons/${iconName}`;
const params = pageData.params ?? {};
const isLab = params.externalLibrary === 'lab';
const url = `https://lucide.dev/icons/${isLab ? 'lab/' : ''}${iconName}`;
const awaitingRelease = Boolean(params.awaitingRelease);
return {
'@context': 'https://schema.org',
@@ -29,18 +41,41 @@ export default async function getStructuredData(iconName: string, pageData: Page
name: iconName,
// TODO: Add support for description extraction from icon metadata
// description: 'An ...SVG icon from the Lucide icon set.',
contentUrl: `https://lucide.dev/api/icons/${iconName}}?width=24&height=24&background=white`,
thumbnailUrl: `https://lucide.dev/api/icons/${iconName}}?width=256&height=256&background=white`,
// `/api/icons/<name>` only renders core icons, a lab name would 404 there.
...(isLab
? {}
: {
contentUrl: `https://lucide.dev/api/icons/${iconName}?width=24&height=24&background=white`,
thumbnailUrl: `https://lucide.dev/api/icons/${iconName}?width=256&height=256&background=white`,
}),
encodingFormat: 'image/svg+xml',
keywords: pageData.params?.tags,
keywords: params.tags,
width: 24,
height: 24,
creator: {
'@type': 'Organization',
name: 'Lucide Icons',
},
datePublished: pageData?.params?.createdRelease?.date,
dateModified: pageData?.params?.changedRelease?.date,
license: LICENSE_URL,
acquireLicensePage: LICENSE_URL,
// The Feather-derived icons are MIT © Cole Bemis; everything else is ISC. Lab
// icons carry no release metadata, so they fall to the ISC-only notice.
copyrightNotice: params.fromFork
? `${LUCIDE_COPYRIGHT}; ${FEATHER_COPYRIGHT}`
: LUCIDE_COPYRIGHT,
creditText: 'Lucide Icons',
// Both dates fall back to the date of the fork for unreleased icons, so neither
...(awaitingRelease
? {}
: {
...(params.createdRelease?.date ? { datePublished: params.createdRelease.date } : {}),
...(params.changedRelease?.date ? { dateModified: params.changedRelease.date } : {}),
}),
// Custom attributes, picked up by the Algolia crawler to rank and sort the icon
// index. Not schema.org vocabulary, consumers that don't know them ignore them.
popularity: params.popularity ?? 0,
awaitingRelease,
},
};
}
@@ -1,10 +1,10 @@
<script setup lang="tsx">
import VPSidebarGroup from 'vitepress/dist/client/theme-default/components/VPSidebarGroup.vue';
import VPNavBarSearch from '../../overrides/VPNavBarSearch.vue';
import sidebar, { guideSidebarTop } from '../../../sidebar';
import { useData, useRouter } from 'vitepress';
import Select from '../base/Select.vue';
import { computed, ref, watch, watchEffect } from 'vue';
import { link, route } from '~/.vitepress/data/iconNodes';
import { computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
const { page } = useData();
@@ -75,6 +75,7 @@ function onSelectFramework(item: Framework) {
</script>
<template>
<VPNavBarSearch v-if="page?.relativePath?.startsWith?.('guide')"/>
<VPSidebarGroup
:items="guideSidebarTop"
v-if="page?.relativePath?.startsWith?.('guide')"
@@ -0,0 +1,203 @@
<script lang="ts">
import { ref } from 'vue'
// This component can be rendered more than once on a page (e.g. a hidden
// copy inside `.content-body`). DocSearch mounts into the first `#docsearch`
// in the document, so only a single instance may ever render the search box.
const loaded = ref(false)
</script>
<script lang="ts" setup>
import '@docsearch/css'
import {
defineAsyncComponent,
onMounted,
onUnmounted,
} from 'vue'
import type { DefaultTheme } from 'vitepress/theme'
import { useData } from 'vitepress/dist/client/theme-default/composables/data'
import FakeInput from '../base/FakeInput.vue'
const VPAlgoliaSearchBox = defineAsyncComponent(() => import('vitepress/dist/client/theme-default/components/VPAlgoliaSearchBox.vue'));
const { theme, page } = useData()
// whether this instance is the one rendering the (hidden) search box
const isOwner = ref(false)
const shortcutText = ref('')
// to avoid loading the docsearch js upfront (which is more than 1/3 of the
// payload), we delay initializing it until the user has actually clicked or
// hit the hotkey to invoke it.
const preconnect = () => {
const id = 'VPAlgoliaPreconnect'
if (document.getElementById(id)) return
const rIC = window.requestIdleCallback || setTimeout
rIC(() => {
const preconnect = document.createElement('link')
preconnect.id = id
preconnect.rel = 'preconnect'
preconnect.href = `https://${
((theme.value.search?.options as DefaultTheme.AlgoliaSearchOptions) ??
theme.value.algolia)!.appId
}-dsn.algolia.net`
preconnect.crossOrigin = ''
document.head.appendChild(preconnect)
})
}
onMounted(() => {
preconnect()
shortcutText.value = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘K' : 'Ctrl+K'
// once docsearch is initialized it handles the hotkey itself
const handleSearchHotKey = (event: KeyboardEvent) => {
if (loaded.value) return remove()
if (
(event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) ||
(!isEditingContent(event) && event.key === '/')
) {
event.preventDefault()
open()
remove()
}
}
const remove = () => {
window.removeEventListener('keydown', handleSearchHotKey)
}
window.addEventListener('keydown', handleSearchHotKey)
onUnmounted(remove)
})
function open() {
if (!loaded.value) {
loaded.value = true
isOwner.value = true
}
setTimeout(poll, 16)
}
function poll() {
// programmatically open the search box after initialize
const e = new Event('keydown') as any
e.key = 'k'
e.metaKey = true
window.dispatchEvent(e)
setTimeout(() => {
if (!document.querySelector('.DocSearch-Modal')) {
poll()
}
}, 16)
}
function isEditingContent(event: KeyboardEvent): boolean {
const element = event.target as HTMLElement
const tagName = element.tagName
return (
element.isContentEditable ||
tagName === 'INPUT' ||
tagName === 'SELECT' ||
tagName === 'TEXTAREA'
)
}
</script>
<template>
<div class="VPNavBarSearch">
<FakeInput
class="nav-search-input"
:shortcut="shortcutText"
aria-label="Search"
@click="open"
>
Search
</FakeInput>
<!-- docsearch renders its own button here; we only need its modal -->
<div v-if="isOwner" class="docsearch-host">
<VPAlgoliaSearchBox :algolia="theme.search?.options ?? theme.algolia" />
</div>
</div>
</template>
<style>
@media (min-width: 768px) {
.VPNavBarSearch {
flex-grow: 1;
padding-left: 0px;
}
}
@media (min-width: 960px) {
.VPNavBarSearch {
padding-left: 0px;
}
}
.VPNavBarSearch {
padding-top: 24px;
}
.VPNavBarSearch .nav-search-input {
padding: 8px 12px;
align-items: center;
width: 100%;
}
.VPNavBarSearch .docsearch-host {
display: none;
}
[class*='DocSearch'] {
--docsearch-primary-color: var(--vp-c-brand-1);
--docsearch-highlight-color: var(--docsearch-primary-color);
--docsearch-text-color: var(--vp-c-text-1);
--docsearch-muted-color: var(--vp-c-text-2);
--docsearch-searchbox-shadow: none;
--docsearch-searchbox-background: transparent;
--docsearch-searchbox-focus-background: transparent;
--docsearch-key-gradient: transparent;
--docsearch-key-shadow: none;
--docsearch-modal-background: var(--vp-c-bg-soft);
--docsearch-footer-background: var(--vp-c-bg);
}
.dark [class*='DocSearch'] {
--docsearch-modal-shadow: none;
--docsearch-footer-shadow: none;
--docsearch-logo-color: var(--vp-c-text-2);
--docsearch-hit-background: var(--vp-c-gray-3);;
--docsearch-container-background: rgba(0, 0, 0, 0.4);
--docsearch-hit-color: var(--vp-c-text-2);
--docsearch-hit-shadow: none;
}
.dark .DocSearch-Footer {
border-top: 1px solid var(--vp-c-divider);
}
.DocSearch-Form {
border: 1px solid var(--vp-c-brand-1);
background-color: var(--vp-c-white);
}
.dark .DocSearch-Form {
background-color: var(--vp-c-default-soft);
}
.DocSearch-Screen-Icon > svg {
margin: auto;
}
</style>
+4
View File
@@ -129,6 +129,10 @@ html {
flex-direction: column-reverse;
}
.content-body .search {
display: none;
}
@media (min-width: 960px) {
.VPHomeHero .container .image {
order: 1;
+24 -24
View File
@@ -4,11 +4,8 @@ import { createGeneralOGImage, createIconOGImage } from './createOGImage';
export async function transformPageData(pageData: PageData) {
pageData.frontmatter.head ??= [];
if (
pageData.relativePath.startsWith('icons/') &&
!pageData.relativePath.startsWith('icons/lab/') &&
pageData.params?.name
) {
if (pageData.relativePath.startsWith('icons/') && pageData.params?.name) {
const isLab = pageData.relativePath.startsWith('icons/lab/');
const iconName = pageData.params.name;
pageData.title = `${iconName} icon details`;
@@ -24,26 +21,29 @@ export async function transformPageData(pageData: PageData) {
const structuredData = await getStructuredData(iconName, pageData);
const ogPath = await createIconOGImage(iconName, pageData.params?.tags || []);
// `createIconOGImage` reads the SVG from `icons/`, where lab icons don't live.
if (!isLab) {
const ogPath = await createIconOGImage(iconName, pageData.params?.tags || []);
if (ogPath) {
const content = `https://lucide.dev${ogPath}`;
pageData.frontmatter.head.push(
[
'meta',
{
property: 'og:image',
content,
},
],
[
'meta',
{
property: 'twitter:image',
content,
},
],
);
if (ogPath) {
const content = `https://lucide.dev${ogPath}`;
pageData.frontmatter.head.push(
[
'meta',
{
property: 'og:image',
content,
},
],
[
'meta',
{
property: 'twitter:image',
content,
},
],
);
}
}
pageData.frontmatter.head.push([
+8 -1
View File
@@ -3,11 +3,18 @@ import iconNodes from '../.vitepress/data/iconNodes';
import * as iconDetails from '../.vitepress/data/iconDetails';
import { IconEntity } from '../.vitepress/theme/types';
/**
* The generated `iconDetails` modules import `iconPopularity/<name>.json` as-is,
* so popularity arrives as the raw `{ count }` object instead of a number.
*/
type IconDetail = Omit<IconEntity, 'popularity'> & { popularity?: { count: number } };
export default {
paths: async () => {
return (Object.values(iconDetails) as unknown as IconEntity[]).map((iconEntity) => {
return (Object.values(iconDetails) as unknown as IconDetail[]).map((iconEntity) => {
const params = {
...iconEntity,
popularity: iconEntity.popularity?.count ?? 0,
relatedIcons: relatedIcons[iconEntity.name].map((name: string) => ({
name,
iconNode: iconNodes[name],
+1
View File
@@ -57,6 +57,7 @@
"vue-tsc": "^3.3.11"
},
"dependencies": {
"@docsearch/css": "3.8.2",
"@floating-ui/vue": "^2.0.1",
"@headlessui/vue": "^1.7.23",
"@lucide/vue": "workspace:*",
+3
View File
@@ -134,6 +134,9 @@ importers:
docs:
dependencies:
'@docsearch/css':
specifier: 3.8.2
version: 3.8.2
'@floating-ui/vue':
specifier: ^2.0.1
version: 2.0.1(vue@3.5.43(typescript@6.0.3))