Adjust search with algolia

This commit is contained in:
Eric Fennis
2026-09-21 08:54:55 +02:00
parent 951813ce76
commit 420e22b5f1
14 changed files with 396 additions and 316 deletions
+14
View File
@@ -0,0 +1,14 @@
# Copy to `docs/.env`. Every variable is optional.
# Icon popularity from Plausible. Defaults to 0 for every icon when unset.
LUCIDE_ANALYTICS_TOKEN=
# Set to `true` to also show icons that are not released yet.
VITE_SHOW_AWAITING_RELEASE=
# Client-side Algolia icon search (public search-only key).
# Overrides the defaults in docs/.vitepress/theme/utils/algolia.ts,
# e.g. to point the site at another index. The index is filled by the Algolia crawler.
VITE_ALGOLIA_APP_ID=
VITE_ALGOLIA_SEARCH_API_KEY=
VITE_ALGOLIA_INDEX_NAME=
+8
View File
@@ -118,6 +118,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: 'docs'
}
},
carbonAds: {
code: 'CWYIC53U',
placement: 'lucidedev',
@@ -1,20 +1,19 @@
<script setup lang="ts">
import { ref, computed, watch, defineAsyncComponent, onMounted } from 'vue';
import type { IconEntity, Category } from '../../types';
import useSearch from '../../composables/useSearch';
import useIconSearch, { entityId } from '../../composables/useIconSearch';
import InputSearch from '../base/InputSearch.vue';
import useSearchInput from '../../composables/useSearchInput';
import useSearchShortcut from '../../utils/useSearchShortcut';
import StickyBar from './StickyBar.vue';
import IconsCategory, { CategoryRow } from './IconsCategory.vue';
import useFetchTags from '../../composables/useFetchTags';
import useFetchCategories from '../../composables/useFetchCategories';
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
import chunkArray from '../../utils/chunkArray';
import useScrollToCategory from '../../composables/useScrollToCategory';
import { useCategoryView } from '../../composables/useCategoryView';
import CarbonAdOverlay from './CarbonAdOverlay.vue';
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
import { prefetchAlgoliaClient } from '../../utils/algolia';
const ICON_SIZE = 56;
const ICON_GRID_GAP = 8;
@@ -28,7 +27,7 @@ const props = defineProps<{
const activeIconName = ref(null);
const { searchInput, searchQuery, searchQueryDebounced } = useSearchInput();
const { selectedCategory } = useCategoryView();
const isSearching = computed(() => !!searchQuery.value);
const isSearching = computed(() => !!searchQueryDebounced.value);
watch(searchQueryDebounced, (searchString) => {
if (searchString !== '') {
@@ -44,13 +43,6 @@ function setActiveIconName(name: string) {
activeIconName.value = name;
}
const { execute: fetchTags, data: tags, isFetching: isFetchingTags } = useFetchTags();
const {
execute: fetchCategories,
data: categoriesMap,
isFetching: isFetchingCategories,
} = useFetchCategories();
const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl);
@@ -58,27 +50,14 @@ const columnSize = computed(() => {
return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
});
const mappedIcons = computed(() => {
if (tags.value == null) {
return props.icons;
}
return props.icons.map((icon) => {
const iconTags = tags.value[icon.name];
const iconCategories = categoriesMap.value?.[icon.name] ?? [];
const icons = computed(() => props.icons);
return {
...icon,
tags: iconTags,
categories: iconCategories,
};
});
});
const { results: searchResults, isPending: isSearchPending } = useIconSearch(
searchQueryDebounced,
icons,
);
const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
{ name: 'name', weight: 3 },
{ name: 'aliases', weight: 3 },
{ name: 'tags', weight: 2 },
]);
const resultIds = computed(() => new Set(searchResults.value.map(entityId)));
const categories = computed(() => {
if (!props.categories?.length || !props.icons?.length) return [];
@@ -92,10 +71,8 @@ const categories = computed(() => {
return iconCategories?.includes(name);
});
const searchedCategoryIcons = isSearching
? categoryIcons.filter((icon) =>
searchResults.value.some((item) => item?.name === icon?.name),
)
const searchedCategoryIcons = isSearching.value
? categoryIcons.filter((icon) => resultIds.value.has(entityId(icon)))
: categoryIcons;
return {
@@ -121,8 +98,8 @@ const categoriesList = computed(() => {
}, []);
});
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
const isSearchMetadataLoading = computed(
() => searchQuery.value.length > 0 && !isFetchingTags && !isFetchingCategories,
const isSearchSettling = computed(
() => searchQuery.value !== searchQueryDebounced.value || isSearchPending.value,
);
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(categoriesList, {
@@ -142,21 +119,6 @@ onMounted(() => {
useEventListener(window, 'scroll', containerProps.onScroll);
});
function loadSearchMetadata() {
if (tags.value == null && !isFetchingTags.value) {
void fetchTags();
}
if (categoriesMap.value == null && !isFetchingCategories.value) {
void fetchCategories();
}
}
watch(searchQuery, (searchString) => {
if (searchString !== '') {
loadSearchMetadata();
}
});
const NoResults = defineAsyncComponent(() => import('./NoResults.vue'));
const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue'));
@@ -191,11 +153,11 @@ function handleCloseDrawer() {
:shortcut="kbdSearchShortcut"
class="input-wrapper"
ref="searchInput"
@focus="loadSearchMetadata"
@focus="prefetchAlgoliaClient"
/>
</StickyBar>
<NoResults
v-if="searchPlaceholder.isNoResults && !isSearchMetadataLoading"
v-if="searchPlaceholder.isNoResults && !isSearchSettling"
:searchQuery="searchPlaceholder.query"
:isBrandSearch="searchPlaceholder.isBrand"
@clear="searchQuery = ''"
@@ -6,21 +6,21 @@ import { useRoute } from 'vitepress';
import IconGrid from './IconGrid.vue';
import Select from '../base/Select.vue';
import InputSearch from '../base/InputSearch.vue';
import useSearch from '../../composables/useSearch';
import useIconSearch from '../../composables/useIconSearch';
import useSearchInput from '../../composables/useSearchInput';
import useSearchShortcut from '../../utils/useSearchShortcut';
import StickyBar from './StickyBar.vue';
import useFetchTags from '../../composables/useFetchTags';
import useFetchCategories from '../../composables/useFetchCategories';
import chunkArray from '../../utils/chunkArray';
import CarbonAdOverlay from './CarbonAdOverlay.vue';
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
import { sortIcons, type SortKey } from '../../utils/sortIcons';
import { prefetchAlgoliaClient } from '../../utils/algolia';
import Icon from '@lucide/vue/src/Icon';
import { listSortDescending } from '~/.vitepress/data/iconNodes';
const ICON_SIZE = 56;
const ICON_GRID_GAP = 8;
const SORTING = [
const SORTING: { name: string; value: SortKey }[] = [
{
name: 'Popularity',
value: 'popularity',
@@ -33,7 +33,7 @@ const SORTING = [
name: 'Name',
value: 'name',
},
]
];
const initialGridItems = computed(() => {
if (containerWidth.value === 0) return 120;
@@ -49,14 +49,7 @@ const props = defineProps<{
}>();
const activeIconName = ref(null);
const selectedSort = ref(SORTING[0])
const { execute: fetchTags, data: tags, isFetching: isFetchingTags } = useFetchTags();
const {
execute: fetchCategories,
data: categories,
isFetching: isFetchingCategories,
} = useFetchCategories();
const selectedSort = ref(SORTING[0]);
const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl);
@@ -65,45 +58,7 @@ const columnSize = computed(() => {
return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
});
const sortedIcons = computed(() => {
switch (selectedSort.value.value) {
case 'popularity':
return [...props.icons].sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
case 'release-date':
return [...props.icons].sort((a, b) => {
if (a.awaitingRelease !== b.awaitingRelease) return a.awaitingRelease ? -1 : 1;
const aDate = a.createdRelease?.date ? new Date(a.createdRelease.date).getTime() : 0;
const bDate = b.createdRelease?.date ? new Date(b.createdRelease.date).getTime() : 0;
return bDate - aDate;
});
case 'name':
return [...props.icons].sort((a, b) => a.name.localeCompare(b.name));
default:
return props.icons;
}
});
const mappedIcons = computed(() => {
if (tags.value == null) {
return sortedIcons.value;
}
if (categories.value == null) {
return sortedIcons.value;
}
return sortedIcons.value.map((icon) => {
const iconTags = tags.value[icon.name];
const iconCategories = categories.value[icon.name] ?? [];
return {
...icon,
tags: iconTags,
categories: iconCategories,
};
});
});
const sortedIcons = computed(() => sortIcons(props.icons, selectedSort.value.value));
const { searchInput, searchQuery, searchQueryDebounced } = useSearchInput();
@@ -111,20 +66,28 @@ const { shortcutText: kbdSearchShortcut } = useSearchShortcut(() => {
searchInput.value?.focus();
});
const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
{ name: 'name', weight: 3 },
{ name: 'aliases', weight: 8 },
{ name: 'tags', weight: 2 },
{ name: 'categories', weight: 1 },
]);
const { results: searchResults, isPending: isSearchPending } = useIconSearch(
searchQueryDebounced,
sortedIcons,
);
// While searching, "Popularity" keeps Algolia's relevance order.
// The other sort options re-sort the matching icons locally.
const displayedIcons = computed(() => {
if (searchQueryDebounced.value && selectedSort.value.value !== 'popularity') {
return sortIcons(searchResults.value, selectedSort.value.value);
}
return searchResults.value;
});
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
const isSearchMetadataLoading = computed(
() => searchQuery.value.length > 0 && !isFetchingTags && !isFetchingCategories,
const isSearchSettling = computed(
() => searchQuery.value !== searchQueryDebounced.value || isSearchPending.value,
);
const chunkedIcons = computed(() => {
return chunkArray(searchResults.value, columnSize.value);
return chunkArray(displayedIcons.value, columnSize.value);
});
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(chunkedIcons, {
@@ -147,21 +110,6 @@ function setActiveIconName(name: string) {
activeIconName.value = name;
}
function loadSearchMetadata() {
if (tags.value == null && !isFetchingTags.value) {
void fetchTags();
}
if (categories.value == null && !isFetchingCategories.value) {
void fetchCategories();
}
}
watch(searchQuery, (searchString) => {
if (searchString !== '') {
loadSearchMetadata();
}
});
const NoResults = defineAsyncComponent(() => import('./NoResults.vue'));
const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue'));
@@ -197,7 +145,7 @@ function handleCloseDrawer() {
ref="searchInput"
:shortcut="kbdSearchShortcut"
class="input-wrapper"
@focus="loadSearchMetadata"
@focus="prefetchAlgoliaClient"
/>
<Select
@@ -216,7 +164,7 @@ function handleCloseDrawer() {
</Select>
</StickyBar>
<NoResults
v-if="searchPlaceholder.isNoResults && !isSearchMetadataLoading"
v-if="searchPlaceholder.isNoResults && !isSearchSettling"
:searchQuery="searchPlaceholder.query"
:isBrandSearch="searchPlaceholder.isBrand"
@clear="searchQuery = ''"
@@ -224,7 +172,7 @@ function handleCloseDrawer() {
<IconGrid
v-else-if="list.length === 0"
overlayMode
:icons="searchResults.slice(0, initialGridItems)"
:icons="displayedIcons.slice(0, initialGridItems)"
:activeIcon="activeIconName"
@setActiveIcon="setActiveIconName"
/>
@@ -1,12 +0,0 @@
import { useFetch } from '@vueuse/core';
const useFetchCategories = () =>
useFetch<Record<string, string[]>>(
`${import.meta.env.DEV ? 'http://localhost:3000' : ''}/api/categories`,
{
immediate:
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('search'),
},
).json();
export default useFetchCategories;
@@ -1,12 +0,0 @@
import { useFetch } from '@vueuse/core';
const useFetchTags = () =>
useFetch<Record<string, string[]>>(
`${import.meta.env.DEV ? 'http://localhost:3000' : ''}/api/tags`,
{
immediate:
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('search'),
},
).json();
export default useFetchTags;
@@ -0,0 +1,81 @@
import { computed, ref, shallowRef, watch, type ComputedRef, type Ref } from 'vue';
import { searchIconIds } from '../utils/algolia';
export interface SearchableIcon {
name: string;
externalLibrary?: string;
}
/**
* Matches the `objectID` scheme of the Algolia `icons` index:
* `name` for core icons, `lab/<name>` for lab icons.
*/
export const entityId = (icon: SearchableIcon) =>
icon.externalLibrary ? `${icon.externalLibrary}/${icon.name}` : icon.name;
export interface UseIconSearchResult<T> {
/** Matching icons in relevance order, or the full collection when the query is empty. */
results: ComputedRef<T[]>;
/** `true` while a search request is in flight. */
isPending: Ref<boolean>;
}
const useIconSearch = <T extends SearchableIcon>(
query: Ref<string>,
collection: Ref<T[]>,
): UseIconSearchResult<T> => {
const byId = computed(() => new Map(collection.value.map((icon) => [entityId(icon), icon])));
const hitIds = shallowRef<string[]>([]);
const isPending = ref(false);
let requestSeq = 0;
watch(
query,
async (searchQuery) => {
const seq = ++requestSeq;
if (!searchQuery) {
isPending.value = false;
return;
}
isPending.value = true;
try {
const ids = await searchIconIds(searchQuery);
if (seq === requestSeq) {
hitIds.value = ids;
}
} catch (error) {
console.error('Icon search failed', error);
if (seq === requestSeq) {
hitIds.value = [];
}
} finally {
if (seq === requestSeq) {
isPending.value = false;
}
}
},
{ immediate: true },
);
const results = computed(() => {
if (!query.value) {
return collection.value;
}
// Hits without a local entity are dropped: lab icons while the Lab library is not
// loaded, or icons the crawler indexed after this build was deployed.
return hitIds.value
.map((id) => byId.value.get(id))
.filter((icon): icon is T => icon !== undefined);
});
return { results, isPending };
};
export default useIconSearch;
@@ -1,32 +0,0 @@
import Fuse, { type FuseOptionKeyObject } from 'fuse.js';
import { shallowRef, computed, Ref } from 'vue';
const useSearch = <T>(
query: Ref<string>,
collection: Ref<T[]>,
keys: FuseOptionKeyObject<T>[] = [],
) => {
const index = shallowRef(
new Fuse(collection.value, {
threshold: 0.2,
useExtendedSearch: true,
keys,
}),
);
const results = computed(() => {
index.value.setCollection(collection.value);
if (query.value) {
return index.value
.search({ $and: query.value.split(' ').filter((t) => !!t) })
.map((result) => result.item);
}
return collection.value;
});
return results;
};
export default useSearch;
+83
View File
@@ -0,0 +1,83 @@
import type { LiteClient } from 'algoliasearch/lite';
/**
* Public search credentials. The search-only key is meant to be shipped in the client
* bundle, so local dev, forks and preview deployments search the production index
* without any configuration. `VITE_ALGOLIA_*` env vars override these defaults.
*
* The index itself is populated by the Algolia crawler; nothing in the docs writes to it.
*/
const DEFAULTS = {
appId: 'R7KKMCPXOB',
searchApiKey: 'dd3101054267d35fb28f49c42715f1aa',
indexName: 'icons',
};
export const ALGOLIA_APP_ID = import.meta.env.VITE_ALGOLIA_APP_ID || DEFAULTS.appId;
export const ALGOLIA_SEARCH_API_KEY =
import.meta.env.VITE_ALGOLIA_SEARCH_API_KEY || DEFAULTS.searchApiKey;
export const ALGOLIA_INDEX_NAME = import.meta.env.VITE_ALGOLIA_INDEX_NAME || DEFAULTS.indexName;
/**
* The index caps retrievable hits at 1000 per query (`paginationLimitedTo`),
* so a single page of this size is the most we can ever get back.
*/
const HITS_PER_PAGE = 1000;
let clientPromise: Promise<LiteClient> | undefined;
/**
* Lazily loads the Algolia lite client so it stays out of the initial page bundle.
* Call it on search input focus to prefetch before the first keystroke.
*/
export function getAlgoliaClient(): Promise<LiteClient> {
clientPromise ??= import('algoliasearch/lite')
.then(({ liteClient }) => liteClient(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY))
.catch((error) => {
// Allow a retry on the next call instead of caching the failure.
clientPromise = undefined;
throw error;
});
return clientPromise;
}
export function prefetchAlgoliaClient() {
getAlgoliaClient().catch(() => {
// Ignored here, the actual search reports errors.
});
}
const cache = new Map<string, string[]>();
/**
* Searches the icon index and returns the matching `objectID`s in relevance order.
* Core icons use their name as `objectID`, lab icons use `lab/<name>`.
*/
export async function searchIconIds(query: string): Promise<string[]> {
const cached = cache.get(query);
if (cached) {
return cached;
}
const client = await getAlgoliaClient();
const {
results: [result],
} = await client.searchForHits<{ objectID: string }>({
requests: [
{
indexName: ALGOLIA_INDEX_NAME,
query,
hitsPerPage: HITS_PER_PAGE,
attributesToRetrieve: ['objectID'],
attributesToHighlight: [],
},
],
});
const ids = result.hits.map((hit) => hit.objectID);
cache.set(query, ids);
return ids;
}
+24
View File
@@ -0,0 +1,24 @@
import type { IconEntity } from '../types';
export type SortKey = 'popularity' | 'release-date' | 'name';
type SortableIcon = Pick<IconEntity, 'name' | 'popularity' | 'createdRelease' | 'awaitingRelease'>;
export function sortIcons<T extends SortableIcon>(icons: T[], sort: SortKey): T[] {
switch (sort) {
case 'popularity':
return [...icons].sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
case 'release-date':
return [...icons].sort((a, b) => {
if (a.awaitingRelease !== b.awaitingRelease) return a.awaitingRelease ? -1 : 1;
const aDate = a.createdRelease?.date ? new Date(a.createdRelease.date).getTime() : 0;
const bDate = b.createdRelease?.date ? new Date(b.createdRelease.date).getTime() : 0;
return bDate - aDate;
});
case 'name':
return [...icons].sort((a, b) => a.name.localeCompare(b.name));
default:
return icons;
}
}
+7
View File
@@ -35,6 +35,13 @@ declare global {
// Defined by VitePress at build time, used by its VPDocAsideCarbonAds component
const __CARBON__: boolean;
// Optional overrides for the Algolia icon search, see docs/.env.example
interface ImportMetaEnv {
readonly VITE_ALGOLIA_APP_ID?: string;
readonly VITE_ALGOLIA_SEARCH_API_KEY?: string;
readonly VITE_ALGOLIA_INDEX_NAME?: string;
}
interface Window {
_carbonads?: unknown;
ExpoSnack?: {
+8
View File
@@ -18,6 +18,14 @@ pnpm docs:dev
pnpm dev
```
## Environment variables
Copy `.env.example` to `.env` if you need any of them. All variables are optional; see the comments in `.env.example`.
## Icon search
Search on `/icons` and `/icons/categories` queries the Algolia `icons` index, which is filled by the Algolia crawler. The site only reads from it (`.vitepress/theme/utils/algolia.ts`), it never writes to it.
## Build
```sh
+1 -1
View File
@@ -61,8 +61,8 @@
"@vueuse/components": "^14.4.0",
"@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.4.0",
"algoliasearch": "^5.59.0",
"element-to-path": "^1.2.1",
"fuse.js": "^7.5.0",
"jszip": "^3.10.1",
"lodash-es": "^4.18.1",
"lucide": "workspace:*",