Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8dfcec367 | ||
|
|
0ab9c70d88 | ||
|
|
26daed2537 | ||
|
|
fa238cf278 | ||
|
|
27f08704a1 | ||
|
|
7c165eb5df | ||
|
|
296ab246a8 | ||
|
|
8300f598a0 | ||
|
|
51be2dac03 | ||
|
|
2333afad1f | ||
|
|
6e1308ce19 | ||
|
|
9a25bb6315 | ||
|
|
d123605682 | ||
|
|
2ae6a3d489 | ||
|
|
1ef60af944 | ||
|
|
cfea992598 | ||
|
|
1ef648145b | ||
|
|
6983a6a175 | ||
|
|
fba5e3f3cc | ||
|
|
58db0a0b0b | ||
|
|
77ac5c4685 | ||
|
|
15c128336f | ||
|
|
924b5fda27 | ||
|
|
ec3c3906f7 |
@@ -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',
|
||||
|
||||
@@ -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,9 @@
|
||||
<script setup lang="tsx">
|
||||
import VPSidebarGroup from 'vitepress/dist/client/theme-default/components/VPSidebarGroup.vue';
|
||||
import sidebar, { guideSidebarTop } from '../../../sidebar';
|
||||
import sidebar 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,14 +74,7 @@ function onSelectFramework(item: Framework) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPSidebarGroup
|
||||
:items="guideSidebarTop"
|
||||
v-if="page?.relativePath?.startsWith?.('guide')"
|
||||
/>
|
||||
<div
|
||||
class="framework-select"
|
||||
v-if="page?.relativePath?.startsWith?.('guide')"
|
||||
>
|
||||
<div class="framework-select">
|
||||
<label for="framework-select">Framework</label>
|
||||
<Select
|
||||
id="framework-select"
|
||||
@@ -93,10 +85,7 @@ function onSelectFramework(item: Framework) {
|
||||
</div>
|
||||
<VPSidebarGroup
|
||||
:key="selected.value"
|
||||
v-if="
|
||||
page?.relativePath?.startsWith?.('guide') &&
|
||||
!page?.relativePath?.startsWith?.(selected.value.substring(1))
|
||||
"
|
||||
v-if="!page?.relativePath?.startsWith?.(selected.value.substring(1))"
|
||||
:items="sidebar[selected.value]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,7 @@ const pickIcons = [
|
||||
'map-pinned',
|
||||
'megaphone',
|
||||
'moon',
|
||||
'music',
|
||||
'sixteenth-notes',
|
||||
'phone',
|
||||
'power-off',
|
||||
'radio',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sun,
|
||||
moon,
|
||||
camera,
|
||||
music,
|
||||
sixteenthNotes,
|
||||
video,
|
||||
globe,
|
||||
layers,
|
||||
@@ -44,7 +44,7 @@ import createElement from 'lucide/src/createElement';
|
||||
import { IconNode } from 'lucide/src/types';
|
||||
|
||||
const icons: Component[] = [
|
||||
heart, star, zap, code, feather, cloud, sun, moon, camera, music,
|
||||
heart, star, zap, code, feather, cloud, sun, moon, camera, sixteenthNotes,
|
||||
video, globe, layers, packageIcon, compass, command, terminal, database,
|
||||
server, cpu, lock, key, shield, wifi, download, upload, search,
|
||||
settings, users, mail, bell, calendar, clock, gitBranch, funnel,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<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/dist/style.css'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import type { DefaultTheme } from 'vitepress/theme'
|
||||
|
||||
import { useData } from 'vitepress'
|
||||
import FakeInput from '../base/FakeInput.vue'
|
||||
|
||||
const VPAlgoliaSearchBox = defineAsyncComponent(() => import('vitepress/dist/client/theme-default/components/VPAlgoliaSearchBox.vue'));
|
||||
|
||||
const { theme } = useData<DefaultTheme.Config>()
|
||||
|
||||
const algolia = computed(
|
||||
() =>
|
||||
(theme.value.search?.options ?? theme.value.algolia) as DefaultTheme.AlgoliaSearchOptions,
|
||||
)
|
||||
|
||||
// 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://${algolia.value.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="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>
|
||||
@@ -10,14 +10,14 @@ import HomeHeroInfoBefore from './components/home/HomeHeroInfoBefore.vue';
|
||||
import { ICON_STYLE_CONTEXT, iconStyleContext } from './composables/useIconStyle';
|
||||
import { CATEGORY_VIEW_CONTEXT, categoryViewContext } from './composables/useCategoryView';
|
||||
import { EXTERNAL_LIBS_CONTEXT, externalLibContext } from './composables/useExternalLibs';
|
||||
import FrameworkSelect from './components/guide/FrameworkSelect.vue';
|
||||
import SideBarNavBefore from './layouts/SideBarNavBefore.vue';
|
||||
import SnackPlayer from './components/editors/SnackPlayer.vue';
|
||||
|
||||
const theme: Partial<Theme> = {
|
||||
extends: DefaultTheme,
|
||||
Layout() {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
'sidebar-nav-before': () => h(FrameworkSelect),
|
||||
'sidebar-nav-before': () => h(SideBarNavBefore),
|
||||
'home-hero-info-before': () => h(HomeHeroInfoBefore),
|
||||
'sidebar-nav-after': () => h(IconsSidebarNavAfter),
|
||||
'home-hero-image': () => h(HomeHeroIconsCard),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import VPSidebarGroup from 'vitepress/dist/client/theme-default/components/VPSidebarGroup.vue';
|
||||
import VPNavBarSearch from '../components/overrides/VPNavBarSearch.vue';
|
||||
import FrameworkSelect from '../components/guide/FrameworkSelect.vue';
|
||||
import { guideSidebarTop } from '../../sidebar';
|
||||
|
||||
const { page } = useData();
|
||||
|
||||
const isGuide = computed(() => page.value?.relativePath?.startsWith?.('guide'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isGuide">
|
||||
<VPNavBarSearch />
|
||||
<VPSidebarGroup :items="guideSidebarTop" />
|
||||
<FrameworkSelect />
|
||||
</template>
|
||||
</template>
|
||||
@@ -129,6 +129,10 @@ html {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.content-body .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.VPHomeHero .container .image {
|
||||
order: 1;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -15,7 +15,7 @@ In an effort to expand the icon set while remaining true to the original minimal
|
||||
|
||||
### Why should I choose Lucide over Feather Icons?
|
||||
|
||||
- Lucide has expanded its icon set by 500+ in the last few years. Lucide now has over 1000 icons, while Feather has around 287 icons.
|
||||
- Lucide has expanded its icon set by 1600+ in the last few years. Lucide now has over 1800 icons, while Feather has around 287 icons.
|
||||
- Well maintained code base.
|
||||
- Active community.
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"author": "Eric Fennis",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@docsearch/js": "3.8.2",
|
||||
"@lucide/build-icons": "workspace:*",
|
||||
"@lucide/helpers": "workspace:*",
|
||||
"@lucide/shared": "workspace:*",
|
||||
@@ -57,6 +58,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:*",
|
||||
@@ -70,9 +72,9 @@
|
||||
"lucide": "workspace:*",
|
||||
"lucide-react": "workspace:*",
|
||||
"motion-v": "2.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"sandpack-vue3": "3.1.12",
|
||||
"vue": "^3.5.43"
|
||||
"vue": "^3.5.44"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"danielbayley",
|
||||
"devkamani9313",
|
||||
"karsa-mistmere"
|
||||
],
|
||||
"use-cases": [
|
||||
"Representing fruit, guacamole, or healthy food in nutrition apps",
|
||||
"Indicating salad, breakfast, or brunch ingredients",
|
||||
"Showing vegetarian or vegan food options",
|
||||
"Marking avocado toast as a menu item in restaurant or delivery apps",
|
||||
"Denoting fresh produce or fruit section in grocery and supermarket apps",
|
||||
"Signifying healthy fats or superfood in nutrition and wellness trackers",
|
||||
"Labeling plant-based or vegetarian recipes in cooking websites and apps"
|
||||
],
|
||||
"tags": [
|
||||
"fruit",
|
||||
"snack",
|
||||
"meal",
|
||||
"lunch",
|
||||
"ingredient",
|
||||
"cooking",
|
||||
"cookery",
|
||||
"kitchen",
|
||||
"cuisine",
|
||||
"eating",
|
||||
"healthy",
|
||||
"nutrition",
|
||||
"diet",
|
||||
"guacamole",
|
||||
"mexican",
|
||||
"green",
|
||||
"pit",
|
||||
"seed",
|
||||
"half",
|
||||
"produce",
|
||||
"vegetarian",
|
||||
"vegan",
|
||||
"grocery",
|
||||
"supermarket",
|
||||
"toast",
|
||||
"superfood",
|
||||
"plant based",
|
||||
"ripe"
|
||||
],
|
||||
"categories": [
|
||||
"food-beverage"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13.465 3.464a5 5 0 017.07 0 5 5 0 010 7.072c-1.204 1.203-2.38 2.89-2.614 4.577A8 8 0 118.886 6.08c1.687-.235 3.375-1.41 4.579-2.615" />
|
||||
<circle cx="10" cy="14" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"danielbayley",
|
||||
"devkamani9313",
|
||||
"karsa-mistmere"
|
||||
],
|
||||
"use-cases": [
|
||||
"Representing breakfast items, rashers, or pork products",
|
||||
"Indicating diner, brunch, or fast food menu items",
|
||||
"Showing savory cooked meats or barbecue items",
|
||||
"Labeling grocery or deli items in food apps",
|
||||
"Marking breakfast sandwich or brunch special add-ons",
|
||||
"Tagging cured or smoked meat ingredients in recipes"
|
||||
],
|
||||
"tags": [
|
||||
"rashers",
|
||||
"smoked",
|
||||
"streaky",
|
||||
"crispy",
|
||||
"meat",
|
||||
"fast food",
|
||||
"junk food",
|
||||
"snack",
|
||||
"dish",
|
||||
"restaurant",
|
||||
"brunch",
|
||||
"lunch",
|
||||
"meal",
|
||||
"savory",
|
||||
"savoury",
|
||||
"cookery",
|
||||
"cooking",
|
||||
"grilled",
|
||||
"barbecue",
|
||||
"barbeque",
|
||||
"bbq",
|
||||
"pig",
|
||||
"pork",
|
||||
"breakfast",
|
||||
"cured",
|
||||
"butcher",
|
||||
"sandwich",
|
||||
"protein",
|
||||
"grocery",
|
||||
"kitchen"
|
||||
],
|
||||
"categories": [
|
||||
"food-beverage"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.795 16.804c-.789-.79-1.09-2.018-.442-2.928 1.933-2.713 4.414-1.05 6.145-5.378 1.868-4.67 4.608-2.366 6.592-6.107.218-.412.78-.53 1.11-.2l5.005 5.005c.789.79 1.09 2.018.442 2.928-1.933 2.713-4.414 1.05-6.145 5.378-1.868 4.67-4.608 2.366-6.591 6.107-.22.412-.78.53-1.11.2z" />
|
||||
<path d="M4.5 18.5c2-5 5-2 7-7s5-2 7-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
@@ -9,17 +9,16 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
|
||||
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516" />
|
||||
<path d="M12 13h4" />
|
||||
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
|
||||
<path d="M12 18h6a2 2 0 012 2v1" />
|
||||
<path d="M12 8h8" />
|
||||
<path d="M16 8V5a2 2 0 0 1 2-2" />
|
||||
<circle cx="16" cy="13" r=".5" />
|
||||
<circle cx="18" cy="3" r=".5" />
|
||||
<circle cx="20" cy="21" r=".5" />
|
||||
<circle cx="20" cy="8" r=".5" />
|
||||
<path d="M12 9a4.17 4.17 0 01-3 4" />
|
||||
<path d="M16 8V5a2 2 0 012-2" />
|
||||
<path d="M6 18a4 4 0 01-2-7.465" />
|
||||
<path d="M6.004 5.125a4 4 0 00-2.526 5.77" />
|
||||
<path d="M6.294 6.294A3 3 0 1112 5v13a4 4 0 11-7.967-.518" />
|
||||
<circle cx="16" cy="13" r="0.5" fill="currentColor" />
|
||||
<circle cx="18" cy="3" r="0.5" fill="currentColor" />
|
||||
<circle cx="20" cy="21" r="0.5" fill="currentColor" />
|
||||
<circle cx="20" cy="8" r="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 744 B |
@@ -12,15 +12,15 @@
|
||||
<path d="m10.852 14.772-.383.923" />
|
||||
<path d="m10.852 9.228-.383-.923" />
|
||||
<path d="m13.148 14.772.382.924" />
|
||||
<path d="m13.531 8.305-.383.923" />
|
||||
<path d="m13.53 8.305-.382.923" />
|
||||
<path d="m14.772 10.852.923-.383" />
|
||||
<path d="m14.772 13.148.923.383" />
|
||||
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446 3 3 0 0 0-.368 1.571 4 4 0 0 0-2.525 5.771" />
|
||||
<path d="M17.998 5.125a4 4 0 0 1 2.525 5.771" />
|
||||
<path d="M19.505 10.294a4 4 0 0 1-1.5 7.706" />
|
||||
<path d="M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516" />
|
||||
<path d="M4.5 10.291A4 4 0 0 0 6 18" />
|
||||
<path d="M6.002 5.125a3 3 0 0 0 .4 1.375" />
|
||||
<path d="M17.997 5.125a4 4 0 012.526 5.77" />
|
||||
<path d="M18 18a4 4 0 002-7.464" />
|
||||
<path d="M4.033 17.483A4 4 0 0011.46 20c.184-.31.896-.31 1.076 0a4 4 0 007.43-2.517" />
|
||||
<path d="M6 18a4 4 0 01-2-7.464" />
|
||||
<path d="M6.002 5.125a4 4 0 00-2.525 5.77" />
|
||||
<path d="M6.293 6.294a3 3 0 01-.287-1.098l-.004-.07a3 3 0 015.968-.543c.05.02.057.01.059 0a3 3 0 015.968.542l-.004.071a3 3 0 01-.287 1.098" />
|
||||
<path d="m9.228 10.852-.923-.383" />
|
||||
<path d="m9.228 13.148-.923.383" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
|
||||
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 957 B |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"karsa-mistmere",
|
||||
"jguddas",
|
||||
"it-is-not",
|
||||
"jamiemlaw",
|
||||
"ej52"
|
||||
],
|
||||
"use-cases": [
|
||||
"Indicating disabled reasoning in an AI chat interface"
|
||||
],
|
||||
"tags": [
|
||||
"medical",
|
||||
"mind",
|
||||
"mental",
|
||||
"intellect",
|
||||
"cerebral",
|
||||
"consciousness",
|
||||
"genius",
|
||||
"artificial intelligence",
|
||||
"ai",
|
||||
"think",
|
||||
"deep learning",
|
||||
"machine learning",
|
||||
"computing"
|
||||
],
|
||||
"categories": [
|
||||
"medical",
|
||||
"science",
|
||||
"development"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 18v-6" />
|
||||
<path d="M12 5v1.344" />
|
||||
<path d="M17.707 6.293A3 3 0 1012 5a3 3 0 00-4.124-2.781" />
|
||||
<path d="M17.997 5.125a4 4 0 012.526 5.77" />
|
||||
<path d="M19.644 19.644A4 4 0 0112 18a4 4 0 11-7.967-.517" />
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M20 10.536a4 4 0 011.533 5.34" />
|
||||
<path d="M3.477 10.895a4 4 0 011.876-5.542" />
|
||||
<path d="M6 18a4 4 0 01-2-7.464" />
|
||||
<path d="M9 13a4.17 4.17 0 002.166-1.5 4 4 0 00.138-.196" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -10,11 +10,11 @@
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 18V5" />
|
||||
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
|
||||
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
|
||||
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
|
||||
<path d="M18 18a4 4 0 0 0 2-7.464" />
|
||||
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
|
||||
<path d="M6 18a4 4 0 0 1-2-7.464" />
|
||||
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
|
||||
<path d="M15 13a4.17 4.17 0 01-3-4 4.17 4.17 0 01-3 4" />
|
||||
<path d="M17.707 6.294A3 3 0 1012 5a3 3 0 10-5.707 1.294" />
|
||||
<path d="M17.996 5.125a4 4 0 012.526 5.77" />
|
||||
<path d="M18 18a4 4 0 002-7.465" />
|
||||
<path d="M19.967 17.482a4 4 0 11-7.967.517 4 4 0 11-7.967-.517" />
|
||||
<path d="M6 18a4 4 0 01-2-7.465" />
|
||||
<path d="M6.004 5.125a4 4 0 00-2.526 5.77" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 601 B After Width: | Height: | Size: 596 B |
@@ -9,11 +9,11 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 20a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" />
|
||||
<path d="m12.474 5.943 1.567 5.34a1 1 0 0 0 1.75.328l2.616-3.402" />
|
||||
<path d="m20 9-3 9" />
|
||||
<path d="m5.594 8.209 2.615 3.403a1 1 0 0 0 1.75-.329l1.567-5.34" />
|
||||
<path d="M7 18 4 9" />
|
||||
<path d="m10.68 5.499-.996 4.982a.65.65 0 01-1.131.295L5.885 7.663" />
|
||||
<path d="m13.32 5.5.995 4.98a.65.65 0 001.131.296l2.668-3.113" />
|
||||
<path d="M20.428 8.952 17.5 18" />
|
||||
<path d="M4 20a2 2 0 012-2h12a2 2 0 012 2v1a1 1 0 01-1 1H5a1 1 0 01-1-1z" />
|
||||
<path d="M6.5 18 3.571 8.952" />
|
||||
<circle cx="12" cy="4" r="2" />
|
||||
<circle cx="20" cy="7" r="2" />
|
||||
<circle cx="4" cy="7" r="2" />
|
||||
|
||||
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 601 B |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"Carlito-la-banane"
|
||||
],
|
||||
"use-cases": [
|
||||
"Representing a load balancer in cloud infrastructure",
|
||||
"Showing traffic distributed across multiple servers",
|
||||
"Indicating a task split into parallel processes",
|
||||
"Representing a load balancer in cloud service consoles",
|
||||
"Showing incoming traffic distributed across multiple backends",
|
||||
"Indicating a task split into parallel worker threads or processes",
|
||||
"Marking fan-out patterns in system architecture or topology diagrams"
|
||||
],
|
||||
"tags": [
|
||||
"load balancer",
|
||||
"distribute",
|
||||
"traffic",
|
||||
"network",
|
||||
"nodes",
|
||||
"parallel",
|
||||
"branch",
|
||||
"hierarchy",
|
||||
"routing",
|
||||
"dispatcher",
|
||||
"cluster",
|
||||
"backend",
|
||||
"fan-out",
|
||||
"topology",
|
||||
"orchestration"
|
||||
],
|
||||
"categories": [
|
||||
"connectivity",
|
||||
"development"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
<circle cx="4" cy="19" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
<circle cx="20" cy="19" r="2" />
|
||||
<path d="M8 7a4 4 0 0 0-4 4v6" />
|
||||
<path d="M12 11v6" />
|
||||
<path d="M16 7a4 4 0 0 1 4 4v6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"madebyoz"
|
||||
],
|
||||
"use-cases": [
|
||||
"Adjusting spacing between characters in text editors and design tools"
|
||||
],
|
||||
"tags": [
|
||||
"tracking",
|
||||
"kerning",
|
||||
"design",
|
||||
"lettering",
|
||||
"text",
|
||||
"letters",
|
||||
"characters",
|
||||
"font",
|
||||
"typography",
|
||||
"formatting"
|
||||
],
|
||||
"categories": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 7a4.95 4.95 0 0 0-8.6-3.4c-1.5 1.6-1.6 1.8-5 2.6a8 8 0 1 0 9.4 9.5c.7-3.4 1-3.6 2.6-5 1-1 1.6-2.3 1.6-3.7" />
|
||||
<circle cx="10" cy="14" r="3.5" />
|
||||
<path d="M15 14H9" />
|
||||
<path d="M21 3v18" />
|
||||
<path d="M3 3v18" />
|
||||
<path d="m8 16 3.453-7.648a.6.6 0 011.094.001L16 16" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 370 B After Width: | Height: | Size: 337 B |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"madebyoz"
|
||||
],
|
||||
"use-cases": [
|
||||
"Adjusting spacing between lines in text editors and design tools"
|
||||
],
|
||||
"tags": [
|
||||
"leading",
|
||||
"text",
|
||||
"letters",
|
||||
"characters",
|
||||
"font",
|
||||
"typography",
|
||||
"spacing",
|
||||
"paragraph",
|
||||
"formatting",
|
||||
"editor",
|
||||
"readability",
|
||||
"typesetting"
|
||||
],
|
||||
"categories": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 14H9" />
|
||||
<path d="M21 21H3" />
|
||||
<path d="M21 3H3" />
|
||||
<path d="m8 16 3.453-7.648a.6.6 0 011.094.001L16 16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"hitanshu-machhi",
|
||||
"colebemis",
|
||||
"csandman",
|
||||
"ericfennis",
|
||||
"karsa-mistmere",
|
||||
"jguddas",
|
||||
"mittalyashu"
|
||||
],
|
||||
"use-cases": [
|
||||
"Representing a user profile in a mobile application",
|
||||
"Indicating a mobile account or device owner",
|
||||
"Showing mobile contact or identity settings",
|
||||
"Displaying a user avatar in a mobile dashboard",
|
||||
"Linking to a contact's profile from a mobile list",
|
||||
"Indicating mobile sign-in or authentication screens",
|
||||
"Marking a device as assigned to a user in device management apps"
|
||||
],
|
||||
"tags": [
|
||||
"phone",
|
||||
"cellphone",
|
||||
"device",
|
||||
"mobile",
|
||||
"person",
|
||||
"profile",
|
||||
"account",
|
||||
"avatar",
|
||||
"contact",
|
||||
"identity",
|
||||
"owner",
|
||||
"authentication"
|
||||
],
|
||||
"categories": [
|
||||
"account",
|
||||
"devices",
|
||||
"people"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 22H6a2 2 0 01-2-2V4a2 2 0 012-2h10a2 2 0 012 2v4" />
|
||||
<path d="M11 18h.01" />
|
||||
<path d="M22 22a4 4 0 00-8 0" />
|
||||
<circle cx="18" cy="15" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"jguddas",
|
||||
"ryck"
|
||||
],
|
||||
"use-cases": [
|
||||
"Marking death or game-over states in games, often paired with skull or ghost icons",
|
||||
"Indicating cemetery or graveyard locations on maps",
|
||||
"Marking deceased individuals in genealogy or family-tree applications",
|
||||
"Flagging obsolete, deprecated, or retired items such as endpoints or pages"
|
||||
],
|
||||
"tags": [
|
||||
"grave",
|
||||
"gravestone",
|
||||
"headstone",
|
||||
"cemetery",
|
||||
"graveyard",
|
||||
"death",
|
||||
"memorial",
|
||||
"funeral",
|
||||
"halloween",
|
||||
"rip",
|
||||
"obituary",
|
||||
"deceased",
|
||||
"epitaph",
|
||||
"mourning",
|
||||
"bereavement",
|
||||
"condolence",
|
||||
"deprecated",
|
||||
"retired",
|
||||
"archived",
|
||||
"decommissioned"
|
||||
],
|
||||
"categories": [
|
||||
"gaming",
|
||||
"navigation",
|
||||
"people",
|
||||
"development"
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 8c-2 5-5 2-7 7s-5 2-7 7l-6-6c2-5 5-2 7-7s5-2 7-7Z" />
|
||||
<path d="M5 19c2-5 5-2 7-7s5-2 7-7" />
|
||||
<path d="M11 10h2" />
|
||||
<path d="M19 21V10a7 7 0 00-14 0v11" />
|
||||
<path d="M2 21h20" />
|
||||
<path d="M9 14h6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 321 B |
@@ -12,8 +12,8 @@
|
||||
"dependencies": {
|
||||
"lucide-react": "workspace:*",
|
||||
"next": "16.3.8",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"@react-router/serve": "8.3.1",
|
||||
"isbot": "5.2.2",
|
||||
"lucide-react": "workspace:*",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-router": "8.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "1.170.32",
|
||||
"lucide-react": "workspace:*",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-plugin": "1.168.35",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"@tanstack/react-router": "1.170.41",
|
||||
"@tanstack/react-start": "1.168.60",
|
||||
"lucide-react": "workspace:*",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "workspace:*",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^8.0.0",
|
||||
"@sveltejs/kit": "^3.0.1",
|
||||
"@sveltejs/kit": "^3.1.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@vitest/browser-playwright": "5.0.3",
|
||||
"playwright": "1.58.2",
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
"dependencies": {
|
||||
"@lucide/vue": "workspace:*",
|
||||
"nuxt": "4.5.1",
|
||||
"vue": "3.5.43",
|
||||
"vue": "3.5.44",
|
||||
"vue-router": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vitest/browser-playwright": "5.0.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"@lucide/vue": "workspace:*",
|
||||
"vike": "0.4.266",
|
||||
"vike-vue": "0.9.13",
|
||||
"vue": "3.5.43"
|
||||
"vue": "3.5.44"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vitest/browser-playwright": "5.0.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "workspace:*",
|
||||
"vue": "3.5.43"
|
||||
"vue": "3.5.44"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.5",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vitest/browser-playwright": "5.0.3",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"playwright": "1.58.2",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"danielbayley"
|
||||
],
|
||||
"tags": [
|
||||
"fruit",
|
||||
"snack",
|
||||
"meal",
|
||||
"lunch",
|
||||
"ingredient",
|
||||
"cooking",
|
||||
"cookery",
|
||||
"kitchen",
|
||||
"cuisine",
|
||||
"eating",
|
||||
"healthy",
|
||||
"nutrition",
|
||||
"diet",
|
||||
"guacamole",
|
||||
"mexican"
|
||||
],
|
||||
"categories": [
|
||||
"food-beverage"
|
||||
]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"$schema": "../icon.schema.json",
|
||||
"contributors": [
|
||||
"danielbayley"
|
||||
],
|
||||
"tags": [
|
||||
"rashers",
|
||||
"smoked",
|
||||
"streaky",
|
||||
"crispy",
|
||||
"meat",
|
||||
"fast food",
|
||||
"junk food",
|
||||
"snack",
|
||||
"dish",
|
||||
"restaurant",
|
||||
"brunch",
|
||||
"lunch",
|
||||
"meal",
|
||||
"savory",
|
||||
"savoury",
|
||||
"cookery",
|
||||
"cooking",
|
||||
"grilled",
|
||||
"barbecue",
|
||||
"barbeque",
|
||||
"bbq",
|
||||
"pig"
|
||||
],
|
||||
"categories": [
|
||||
"food-beverage"
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"generate:sponsors": "node scripts/updateSponsors.mts",
|
||||
"generate:contributors": "node ./scripts/updateContributors.mts icons/*.svg",
|
||||
"generate:nextJSAliases": "node ./scripts/generateNextJSAliases.mts",
|
||||
"icon-usage": "node ./scripts/iconUsage.mts",
|
||||
"suggest:metadata": "node ./scripts/suggestMetaData.mts",
|
||||
"suggest:metadata:debug": "node --env-file .env ./scripts/suggestMetaData.mts --debug",
|
||||
"suggest:metadata:watch": "node --env-file .env --watch ./scripts/suggestMetaData.mts",
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jest-serializer-html": "^7.1.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-native": "^0.87.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-native": "^0.88.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"rollup": "^4.63.1",
|
||||
"rollup-plugin-dts": "^6.5.1",
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jest-serializer-html": "^7.1.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"rollup": "^4.63.1",
|
||||
"rollup-plugin-dts": "^6.5.1",
|
||||
"rollup-plugin-preserve-directives": "^0.4.0",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"devDependencies": {
|
||||
"@lucide/build-icons": "workspace:*",
|
||||
"@lucide/helpers": "workspace:*",
|
||||
"@sveltejs/package": "^3.0.0",
|
||||
"@sveltejs/package": "^3.0.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/svelte": "^5.4.2",
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
"@lucide/shared": "workspace:*",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/test-utils": "2.5.1",
|
||||
"rollup": "^4.63.1",
|
||||
"rollup-plugin-dts": "^6.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
"vue": "^3.5.43"
|
||||
"vue": "^3.5.44"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Lucide Icon Usage Estimator
|
||||
|
||||
Run with:
|
||||
|
||||
```sh
|
||||
pnpm icon-usage trash trash-2
|
||||
```
|
||||
|
||||
The default is a bounded first pass over `lucide-react`, with two GitHub search pages and up to 200 candidate files validated per query slice. Use `--all-packages` to include every supported package, or `--thorough` for the previous broader limits of 10 pages and 1,000 candidate files per query slice.
|
||||
|
||||
GitHub code search requires authentication, so set `GITHUB_TOKEN` or pass `--token-env <NAME>`.
|
||||
|
||||
Use `--verbose` or `-v` to print detailed progress logs to stderr, including queries, pages, cache hits, retries, content validation, and matches.
|
||||
|
||||
You can pass a single icon, and icon names may be kebab-case (`trash-2`) or export names (`Trash2`, `Trash2Icon`, `LucideTrash2`). Use `--icons-file <path>` to read names from a file with one name per line, such as a list of `lucide-react` exports.
|
||||
|
||||
## Usage In Context
|
||||
|
||||
```sh
|
||||
pnpm icon-usage trash-2 --context
|
||||
pnpm icon-usage trash-2 --describe
|
||||
pnpm icon-usage --icons-file exports.txt --describe --max-contexts 30
|
||||
```
|
||||
|
||||
`--context` extracts how each icon is used in every validated file: what kind of usage it is (a JSX/template element, a prop such as `icon={Trash2}`, a config object such as `{ title: 'Settings', icon: Settings }`, or a `data-lucide` attribute), the parent elements (for example `DropdownMenuItem` or `Button`), the enclosing component, label text (`aria-label`, `title`, visible text, i18n keys like `t('actions.delete')`), event handlers, links, and a short code snippet. Each usage gets a heuristic description such as `Trash2 in <DropdownMenuItem> labelled "Delete Project" within NavProjects`. Each icon gets a summary of the most common labels, parents and handlers, plus example links to the exact lines on GitHub.
|
||||
|
||||
`--describe` does the same, then sends the collected contexts to an OpenAI model (`OPENAI_API_KEY` required; `--model` or `OPENAI_MODEL` selects the model, `gpt-5-mini` by default). The model groups them into 1 to 4 use cases per icon. Each has a `useCase` array of 1 to 3 phrases for the `use-cases` field in `icons/*.json`, written following the "Use Cases" section of `.github/instructions/metadata.instructions.md` (loaded into the prompt at runtime), a description of where the icon appears in the UI, its approximate share of the sample, and example links. The icon's existing `use-cases` are included in the prompt. Responses are cached in the cache directory.
|
||||
|
||||
Use `--output <file>` (or `-o`) to write the results per icon to a JSON file shaped as `{ [icon]: [...usage] }`. With `--describe`, each entry is a use case (`useCase`, `description`, `sampleShare`, `examples`). Without it, each entry is an individual usage (`description`, `repository`, `url`, `kind`, `parents`, `component`, `labels`, `handlers`, `links`). `--output` implies `--context`, and the normal terminal, `--json` or `--csv` output is still printed.
|
||||
|
||||
```sh
|
||||
pnpm icon-usage --icons-file exports.txt --describe --output usage.json
|
||||
```
|
||||
|
||||
To keep runs bounded, searching for an icon stops once `--max-contexts` contexts (default 40) are collected, and at most two contexts are taken from each repository. When the limit is hit, the repository counts for that icon are a lower bound, and a warning says so. JSON output includes every context per match under `repositories.*.matches[].contexts`, and per-icon summaries and descriptions under `usage`.
|
||||
|
||||
Context extraction uses heuristics, not a full parser. It works best for JSX (React, Preact, Solid), Vue, Svelte and Astro templates in the same file as the import. Angular templates in separate `.html` files are not fetched.
|
||||
|
||||
## Method
|
||||
|
||||
The script uses GitHub REST `GET /search/code` with package-scoped, code-extension-scoped queries, then fetches each returned file and validates common Lucide import styles before counting a repository. Counts are deduplicated by `owner/repository`.
|
||||
|
||||
Output includes overall repository counts and per-package statistics. Per-package statistics are computed from validated matches for that package, so a repository using `trash` via `lucide-react` and `trash-2` via another package is represented correctly in both the overall totals and each package section.
|
||||
|
||||
Supported packages are `lucide-react`, `@lucide/vue`, `@lucide/svelte`, `@lucide/angular`, `@lucide/astro`, `lucide-preact`, `lucide-solid`, `lucide`, and `@lucide/lab`. The default package is `lucide-react` because all packages plus file-scope slicing is slow for broad icons; use `--all-packages` when that broader sample is needed.
|
||||
|
||||
Searches are split across relevant implementation file scopes using REST code search qualifiers such as `language:typescript`, `language:javascript`, `extension:vue`, and `extension:svelte`. Returned paths are still filtered by expected extensions before content is fetched, so Markdown and prompt/spec files are intentionally excluded by default because they frequently contain examples or generated implementation notes rather than live package usage.
|
||||
|
||||
By default, queries exclude forks, archived repositories, generated files, and vendored files using GitHub code search qualifiers. Use `--include-forks`, `--include-archived`, `--include-generated`, or `--include-vendored` to include them.
|
||||
|
||||
## GitHub API Limits
|
||||
|
||||
GitHub REST code search is authenticated and rate-limited to 10 requests per minute. Each query can return at most 1,000 results, with up to 100 results per page. Search only covers default branches and files smaller than 384 KB.
|
||||
|
||||
`total_count` and `incomplete_results` come from GitHub search and are recorded in JSON metadata. If a query exceeds the 1,000-result cap or returns `incomplete_results: true`, the script reports a warning and the result should be treated as incomplete rather than exhaustive.
|
||||
|
||||
GitHub's newer code search syntax is usable for public code search queries and supports qualifiers such as `NOT is:fork`, `NOT is:archived`, `NOT is:generated`, and `NOT is:vendored`, but the documented REST code search endpoint remains capped for result retrieval.
|
||||
|
||||
## Practical Strategy For Broad Icons
|
||||
|
||||
For common icons that hit search caps, use the script output as lower-bound or sampled evidence. The most defensible follow-up is to narrow query slices and cite them separately, for example by package, language/path, or repository cohorts, while preserving the JSON metadata for every query performed.
|
||||
|
||||
## Retries And Partial Results
|
||||
|
||||
GitHub requests that time out or fail temporarily (`408`, `429`, `5xx`, rate-limit `403`, network errors) are retried up to 6 times with backoff, honouring `Retry-After` and rate-limit reset headers. If a request still fails, that search slice or file is skipped with a warning instead of stopping the run. The OpenAI client used by `--describe` retries up to 5 times.
|
||||
|
||||
Every successful GitHub and OpenAI response is cached in the cache directory as soon as it arrives. If a run fails or is interrupted, running the same command again replays the cached responses without waiting, and only fetches what is missing.
|
||||
|
||||
With `--output`, each icon is finished (including `--describe`) before the next one starts, and the file is rewritten after every icon. An interrupted run therefore leaves a valid JSON file with every completed icon.
|
||||
|
||||
## Known Biases
|
||||
|
||||
Only public GitHub repositories visible to the token are observable. Private repositories and consumers outside GitHub are absent. GitHub is not representative of every Lucide consumer. Framework/package usage can differ materially. GitHub indexing may be incomplete or delayed. Generated or vendored code can still create false positives when GitHub does not identify it. Very common icons may exceed GitHub search-result caps.
|
||||
@@ -0,0 +1,657 @@
|
||||
// Extracts how an icon is used inside a source file (surrounding element,
|
||||
// label text, handlers, config objects) and aggregates those usages into
|
||||
// per-icon descriptions. Used by `iconUsage.mts` with `--context`/`--describe`.
|
||||
|
||||
export type UsageContext = {
|
||||
line: number;
|
||||
kind: 'element' | 'prop' | 'object' | 'reference' | 'data-attribute';
|
||||
localName: string;
|
||||
hostTag: string | null;
|
||||
ancestors: string[];
|
||||
component: string | null;
|
||||
labels: string[];
|
||||
i18nKeys: string[];
|
||||
handlers: string[];
|
||||
links: string[];
|
||||
iconProps: string | null;
|
||||
snippet: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ContextMatch = {
|
||||
repository: string;
|
||||
path: string;
|
||||
htmlUrl: string;
|
||||
context: UsageContext;
|
||||
};
|
||||
|
||||
export type IconContextSummary = {
|
||||
contexts: number;
|
||||
repositories: number;
|
||||
topLabels: Array<{ value: string; count: number }>;
|
||||
topHosts: Array<{ value: string; count: number }>;
|
||||
topAncestors: Array<{ value: string; count: number }>;
|
||||
topHandlers: Array<{ value: string; count: number }>;
|
||||
kinds: Record<string, number>;
|
||||
examples: Array<{ repository: string; htmlUrl: string; description: string }>;
|
||||
};
|
||||
|
||||
const MAX_CONTEXTS_PER_FILE = 3;
|
||||
const SNIPPET_LINES_BEFORE = 3;
|
||||
const SNIPPET_LINES_AFTER = 3;
|
||||
const LOOKBEHIND_CHARS = 2500;
|
||||
const MAX_SNIPPET_LINE_LENGTH = 160;
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const toKebabCase = (value: string) =>
|
||||
value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
||||
.toLowerCase();
|
||||
|
||||
const unique = <T,>(values: T[]) => [...new Set(values)];
|
||||
|
||||
const clean = (value: string) => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* Resolves the identifiers a file uses for an icon, following `as` aliases
|
||||
* and default imports of direct icon paths (e.g. `lucide-react/icons/trash`).
|
||||
*/
|
||||
export const localNamesFor = (
|
||||
source: string,
|
||||
packageName: string,
|
||||
icon: string,
|
||||
usageNames: string[],
|
||||
) => {
|
||||
const names = new Set<string>();
|
||||
const escapedPackage = escapeRegExp(packageName);
|
||||
const namedImportRegex = new RegExp(
|
||||
`import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+['"]${escapedPackage}['"]`,
|
||||
'g',
|
||||
);
|
||||
for (const match of source.matchAll(namedImportRegex)) {
|
||||
for (const specifier of (match[1] ?? '').split(',')) {
|
||||
const parts = specifier
|
||||
.trim()
|
||||
.replace(/^type\s+/, '')
|
||||
.split(/\s+as\s+/);
|
||||
if (!usageNames.includes(parts[0]?.trim() ?? '')) continue;
|
||||
names.add((parts[1] ?? parts[0]).trim());
|
||||
}
|
||||
}
|
||||
|
||||
const directImportRegex = new RegExp(
|
||||
`import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"]${escapeRegExp(`${packageName}/icons/${icon}`)}(?:\\.js)?['"]`,
|
||||
'g',
|
||||
);
|
||||
for (const match of source.matchAll(directImportRegex)) names.add(match[1]);
|
||||
|
||||
// Packages without imports in the template (vanilla `lucide`, Angular) are
|
||||
// referenced by their export names directly.
|
||||
if (names.size === 0) usageNames.forEach((name) => names.add(name));
|
||||
return [...names];
|
||||
};
|
||||
|
||||
/** Character ranges that are import/export statements and must not count as usage. */
|
||||
const statementRanges = (source: string) => {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
const regex =
|
||||
/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[\w$*\s,]*\{[\s\S]*?\}|[\w$*\s,]+?)\s*from\s*['"][^'"]+['"]/g;
|
||||
for (const match of source.matchAll(regex)) {
|
||||
ranges.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
|
||||
type Tag = { name: string; start: number; end: number; attributes: string };
|
||||
|
||||
/**
|
||||
* Finds the end of a JSX/HTML tag that starts at `start`, skipping over `{}`
|
||||
* expressions, strings and arrow functions so `onClick={() => a > b}` does not
|
||||
* end the tag early.
|
||||
*/
|
||||
const findTagEnd = (source: string, start: number, limit: number) => {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let index = start + 1; index < limit; index += 1) {
|
||||
const char = source[index];
|
||||
if (quote) {
|
||||
if (char === quote && source[index - 1] !== '\\') quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === '`') quote = char;
|
||||
else if (char === '{') depth += 1;
|
||||
else if (char === '}') depth = Math.max(0, depth - 1);
|
||||
else if (char === '>' && depth === 0 && source[index - 1] !== '=') return index;
|
||||
else if (
|
||||
char === '<' &&
|
||||
depth === 0 &&
|
||||
index > start + 1 &&
|
||||
/[A-Za-z/]/.test(source[index + 1] ?? '')
|
||||
)
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'img',
|
||||
'input',
|
||||
'br',
|
||||
'hr',
|
||||
'meta',
|
||||
'link',
|
||||
'source',
|
||||
'area',
|
||||
'col',
|
||||
'embed',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Scans markup before `position` and returns the still-open elements
|
||||
* (innermost last) plus the tag `position` sits inside, if any.
|
||||
*/
|
||||
const openElementsAt = (source: string, position: number) => {
|
||||
const windowStart = Math.max(0, position - LOOKBEHIND_CHARS);
|
||||
const stack: Tag[] = [];
|
||||
let host: Tag | null = null;
|
||||
const tagRegex = /<(\/?)([A-Za-z][\w.:-]*)/g;
|
||||
tagRegex.lastIndex = windowStart;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRegex.exec(source)) && match.index < position) {
|
||||
const [, closing, name] = match;
|
||||
// `a < b` or generics like `Array<string>` are not markup.
|
||||
const before = source[match.index - 1] ?? '';
|
||||
if (!closing && /[\w$)\]]/.test(before)) continue;
|
||||
const end = findTagEnd(source, match.index, Math.min(source.length, position + 2000));
|
||||
if (end === -1) continue;
|
||||
const tag = { name, start: match.index, end, attributes: source.slice(match.index, end + 1) };
|
||||
if (end >= position) {
|
||||
if (!closing) host = tag;
|
||||
break;
|
||||
}
|
||||
if (closing) {
|
||||
const openIndex = stack.map((item) => item.name).lastIndexOf(name);
|
||||
if (openIndex !== -1) stack.splice(openIndex);
|
||||
} else if (source[end - 1] !== '/' && !VOID_ELEMENTS.has(name.toLowerCase())) {
|
||||
stack.push(tag);
|
||||
}
|
||||
tagRegex.lastIndex = end + 1;
|
||||
}
|
||||
return { stack, host };
|
||||
};
|
||||
|
||||
const LABEL_ATTRIBUTES = [
|
||||
'aria-label',
|
||||
'title',
|
||||
'label',
|
||||
'tooltip',
|
||||
'alt',
|
||||
'placeholder',
|
||||
'content',
|
||||
'text',
|
||||
'name',
|
||||
];
|
||||
|
||||
const attributeValues = (markup: string, names: string[]) => {
|
||||
const values: string[] = [];
|
||||
const pattern = names.map(escapeRegExp).join('|');
|
||||
const regex = new RegExp(
|
||||
`(?:^|[\\s{,])(?:${pattern})\\s*[=:]\\s*(?:\\{\\s*)?(?:"([^"]{1,80})"|'([^']{1,80})'|\`([^\`$]{1,80})\`)`,
|
||||
'g',
|
||||
);
|
||||
for (const match of markup.matchAll(regex)) {
|
||||
const value = clean(match[1] ?? match[2] ?? match[3] ?? '');
|
||||
if (value) values.push(value);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const handlerValues = (markup: string) => {
|
||||
const values: string[] = [];
|
||||
const regex =
|
||||
/(?:^|[\s(])(on[A-Z]\w*|@[\w.-]+|v-on:[\w.-]+|\(click\)|on:\w+|onclick)\s*=\s*\{?\s*["']?(?:\(\)\s*=>\s*)?([\w$.]+)/g;
|
||||
for (const match of markup.matchAll(regex)) {
|
||||
if (match[2] && !['function', 'async', 'e', 'event'].includes(match[2])) {
|
||||
values.push(`${match[1]}=${match[2]}`);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const linkValues = (markup: string) =>
|
||||
attributeValues(markup, ['href', 'to', 'url', 'path', 'route', 'routerLink']);
|
||||
|
||||
const i18nKeys = (markup: string) => {
|
||||
const keys: string[] = [];
|
||||
const regex =
|
||||
/\b(?:t|i18n\.t|\$t|translate|formatMessage|intl\.formatMessage)\(\s*(?:\{\s*id:\s*)?['"`]([\w.:-]{2,80})['"`]/g;
|
||||
for (const match of markup.matchAll(regex)) keys.push(match[1]);
|
||||
return keys;
|
||||
};
|
||||
|
||||
/** Content range of an element (between its open and close tags), bounded in size. */
|
||||
const elementContent = (source: string, parent: Tag | undefined, fallbackStart: number) => {
|
||||
const start = parent ? parent.end + 1 : fallbackStart;
|
||||
const closing = parent ? source.indexOf(`</${parent.name}`, start) : -1;
|
||||
const end =
|
||||
closing === -1 ? Math.min(source.length, start + 300) : Math.min(closing, start + 600);
|
||||
return source.slice(start, end);
|
||||
};
|
||||
|
||||
/** Visible text inside an element, with nested tags and expressions removed. */
|
||||
const elementText = (source: string, parent: Tag | undefined, fallbackStart: number) => {
|
||||
const content = elementContent(source, parent, fallbackStart);
|
||||
const text = content
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\{[^{}]*\}/g, ' ')
|
||||
.replace(/[{}]/g, ' ');
|
||||
const value = clean(text);
|
||||
if (!value || value.length > 80 || /[;=]|=>|\breturn\b/.test(value)) return [];
|
||||
return [value];
|
||||
};
|
||||
|
||||
const enclosingComponent = (source: string, position: number) => {
|
||||
const before = source.slice(Math.max(0, position - 20000), position);
|
||||
const regex =
|
||||
/(?:function\s+([A-Z][\w$]*)|(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:\([^)]*\)|[\w$]+)?\s*(?:=>|function)|class\s+([A-Z][\w$]*))/g;
|
||||
let name: string | null = null;
|
||||
for (const match of before.matchAll(regex)) name = match[1] ?? match[2] ?? match[3] ?? name;
|
||||
return name;
|
||||
};
|
||||
|
||||
const snippetAround = (lines: string[], lineIndex: number) =>
|
||||
lines
|
||||
.slice(Math.max(0, lineIndex - SNIPPET_LINES_BEFORE), lineIndex + SNIPPET_LINES_AFTER + 1)
|
||||
.map((line) =>
|
||||
line.length > MAX_SNIPPET_LINE_LENGTH ? `${line.slice(0, MAX_SNIPPET_LINE_LENGTH)}…` : line,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
/** Object literal around a reference such as `{ title: 'Settings', icon: Settings }`. */
|
||||
const objectLiteralAround = (source: string, position: number) => {
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
for (let index = position; index >= Math.max(0, position - 600); index -= 1) {
|
||||
const char = source[index];
|
||||
if (char === '}') depth += 1;
|
||||
else if (char === '{') {
|
||||
if (depth === 0) {
|
||||
start = index;
|
||||
break;
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if (start === -1) return null;
|
||||
depth = 0;
|
||||
for (let index = start; index < Math.min(source.length, start + 1200); index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const describe = (context: Omit<UsageContext, 'description' | 'snippet'>) => {
|
||||
const parent = context.ancestors.at(-1);
|
||||
const where =
|
||||
context.kind === 'object'
|
||||
? 'config object'
|
||||
: context.kind === 'prop'
|
||||
? `\`${context.hostTag}\` prop`
|
||||
: parent
|
||||
? `<${parent}>`
|
||||
: context.kind === 'data-attribute'
|
||||
? `<${context.hostTag ?? 'i'} data-lucide>`
|
||||
: 'standalone element';
|
||||
const parts = [`${context.localName} in ${where}`];
|
||||
const label = context.labels[0] ?? context.i18nKeys[0];
|
||||
if (label) parts.push(`labelled "${label}"`);
|
||||
if (context.handlers.length > 0)
|
||||
parts.push(`handling ${context.handlers.slice(0, 2).join(', ')}`);
|
||||
if (context.links.length > 0) parts.push(`linking to ${context.links[0]}`);
|
||||
if (context.iconProps && /spin|animate|loading/i.test(context.iconProps))
|
||||
parts.push('(animated)');
|
||||
if (context.component) parts.push(`within ${context.component}`);
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
/** Extracts up to a few usage contexts of an icon from a single source file. */
|
||||
export const extractContexts = (
|
||||
source: string,
|
||||
packageName: string,
|
||||
icon: string,
|
||||
usageNames: string[],
|
||||
): UsageContext[] => {
|
||||
const localNames = localNamesFor(source, packageName, icon, usageNames);
|
||||
const ranges = statementRanges(source);
|
||||
const lines = source.split('\n');
|
||||
const lineStarts: number[] = [];
|
||||
lines.reduce((offset, line) => {
|
||||
lineStarts.push(offset);
|
||||
return offset + line.length + 1;
|
||||
}, 0);
|
||||
const lineAt = (position: number) => {
|
||||
let low = 0;
|
||||
let high = lineStarts.length - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
if (lineStarts[mid] <= position) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
const namePattern = unique([
|
||||
...localNames.map(escapeRegExp),
|
||||
// Vue templates may use kebab-case component names.
|
||||
...(packageName === '@lucide/vue'
|
||||
? localNames.map((name) => escapeRegExp(toKebabCase(name)))
|
||||
: []),
|
||||
]).join('|');
|
||||
const occurrences: Array<{ position: number; name: string; dataAttribute: boolean }> = [];
|
||||
for (const match of source.matchAll(
|
||||
new RegExp(`(?<![\\w$.'"/-])(${namePattern})(?![\\w$-])`, 'g'),
|
||||
)) {
|
||||
occurrences.push({ position: match.index ?? 0, name: match[1], dataAttribute: false });
|
||||
}
|
||||
if (packageName === 'lucide') {
|
||||
for (const match of source.matchAll(
|
||||
new RegExp(`data-lucide\\s*=\\s*["']${escapeRegExp(icon)}["']`, 'g'),
|
||||
)) {
|
||||
occurrences.push({ position: match.index ?? 0, name: icon, dataAttribute: true });
|
||||
}
|
||||
}
|
||||
|
||||
const contexts: UsageContext[] = [];
|
||||
const seenLines = new Set<number>();
|
||||
for (const { position, name, dataAttribute } of occurrences.sort(
|
||||
(a, b) => a.position - b.position,
|
||||
)) {
|
||||
if (contexts.length >= MAX_CONTEXTS_PER_FILE) break;
|
||||
if (ranges.some(([start, end]) => position >= start && position < end)) continue;
|
||||
const lineIndex = lineAt(position);
|
||||
if (seenLines.has(lineIndex)) continue;
|
||||
const lineText = lines[lineIndex];
|
||||
if (/^\s*(\/\/|\*|\/\*)/.test(lineText)) continue;
|
||||
// Registration calls such as `createIcons({ icons: { Trash } })` carry no context.
|
||||
if (
|
||||
/(?:createIcons\s*\(|LucideAngularModule\.pick\s*\(|addIcons\s*\(|icons\s*:\s*\{)[^}<]*$/.test(
|
||||
source.slice(Math.max(0, position - 300), position),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
const isElement = source[position - 1] === '<';
|
||||
const { stack, host } = isElement
|
||||
? openElementsAt(source, position - 1)
|
||||
: openElementsAt(source, position);
|
||||
const ownTag = isElement
|
||||
? (() => {
|
||||
const end = findTagEnd(source, position - 1, Math.min(source.length, position + 2000));
|
||||
return end === -1 ? null : source.slice(position - 1, end + 1);
|
||||
})()
|
||||
: null;
|
||||
|
||||
let kind: UsageContext['kind'] = 'reference';
|
||||
let hostTag: string | null = null;
|
||||
let markupForSignals = '';
|
||||
if (dataAttribute) {
|
||||
kind = 'data-attribute';
|
||||
hostTag = host?.name ?? null;
|
||||
} else if (isElement) {
|
||||
kind = 'element';
|
||||
} else if (host) {
|
||||
kind = 'prop';
|
||||
hostTag = host.name;
|
||||
markupForSignals = host.attributes;
|
||||
} else {
|
||||
const object = objectLiteralAround(source, position);
|
||||
if (object && new RegExp(`[\\w$]+\\s*:\\s*${escapeRegExp(name)}\\b`).test(object)) {
|
||||
kind = 'object';
|
||||
markupForSignals = object;
|
||||
}
|
||||
}
|
||||
|
||||
const ancestors = stack.slice(-3).map((tag) => tag.name);
|
||||
const parent = stack.at(-1);
|
||||
const ancestorMarkup = stack
|
||||
.slice(-2)
|
||||
.map((tag) => tag.attributes)
|
||||
.join(' ');
|
||||
const signalMarkup = [markupForSignals, ancestorMarkup].join(' ');
|
||||
const labels = unique([
|
||||
...(kind === 'object' ? attributeValues(markupForSignals, LABEL_ATTRIBUTES) : []),
|
||||
...attributeValues(
|
||||
signalMarkup,
|
||||
LABEL_ATTRIBUTES.filter((attr) => attr !== 'name'),
|
||||
),
|
||||
...(kind === 'element' || kind === 'data-attribute' || kind === 'prop'
|
||||
? elementText(source, kind === 'prop' && host ? host : parent, position)
|
||||
: []),
|
||||
]).slice(0, 4);
|
||||
|
||||
const base = {
|
||||
line: lineIndex + 1,
|
||||
kind,
|
||||
localName: name,
|
||||
hostTag,
|
||||
ancestors,
|
||||
component: enclosingComponent(source, position),
|
||||
labels,
|
||||
i18nKeys: unique(
|
||||
i18nKeys(
|
||||
kind === 'object'
|
||||
? markupForSignals
|
||||
: `${signalMarkup} ${elementContent(source, kind === 'prop' && host ? host : parent, position)}`,
|
||||
),
|
||||
).slice(0, 3),
|
||||
handlers: unique(handlerValues(signalMarkup)).slice(0, 3),
|
||||
links: unique(linkValues(signalMarkup)).slice(0, 2),
|
||||
iconProps: ownTag
|
||||
? clean(ownTag.slice(name.length + 1, -1).replace(/\/$/, '')).slice(0, 120) || null
|
||||
: null,
|
||||
};
|
||||
seenLines.add(lineIndex);
|
||||
contexts.push({
|
||||
...base,
|
||||
snippet: snippetAround(lines, lineIndex),
|
||||
description: describe(base),
|
||||
});
|
||||
}
|
||||
return contexts;
|
||||
};
|
||||
|
||||
const topValues = (values: string[], limit = 8) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([value, count]) => ({ value, count }));
|
||||
};
|
||||
|
||||
/** Aggregates the contexts collected for one icon into frequency tables. */
|
||||
export const summarizeContexts = (matches: ContextMatch[]): IconContextSummary => {
|
||||
const contexts = matches.map((match) => match.context);
|
||||
const kinds: Record<string, number> = {};
|
||||
for (const context of contexts) kinds[context.kind] = (kinds[context.kind] ?? 0) + 1;
|
||||
// Prefer one example per repository so a single project cannot dominate.
|
||||
const examples: IconContextSummary['examples'] = [];
|
||||
const exampleRepos = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (exampleRepos.has(match.repository)) continue;
|
||||
exampleRepos.add(match.repository);
|
||||
examples.push({
|
||||
repository: match.repository,
|
||||
htmlUrl: `${match.htmlUrl}#L${match.context.line}`,
|
||||
description: match.context.description,
|
||||
});
|
||||
if (examples.length >= 10) break;
|
||||
}
|
||||
return {
|
||||
contexts: contexts.length,
|
||||
repositories: new Set(matches.map((match) => match.repository)).size,
|
||||
topLabels: topValues(
|
||||
contexts.flatMap((context) =>
|
||||
unique([...context.labels, ...context.i18nKeys].map((value) => value.toLowerCase())),
|
||||
),
|
||||
),
|
||||
topHosts: topValues(contexts.flatMap((context) => (context.hostTag ? [context.hostTag] : []))),
|
||||
topAncestors: topValues(contexts.flatMap((context) => context.ancestors.slice(-1))),
|
||||
topHandlers: topValues(
|
||||
contexts.flatMap((context) => context.handlers.map((handler) => handler.split('=')[0])),
|
||||
),
|
||||
kinds,
|
||||
examples,
|
||||
};
|
||||
};
|
||||
|
||||
export type UsageDescription = {
|
||||
useCase: string[];
|
||||
description: string;
|
||||
sampleShare: number;
|
||||
examples: string[];
|
||||
};
|
||||
|
||||
type DescribeOptions = {
|
||||
icon: string;
|
||||
matches: ContextMatch[];
|
||||
summary: IconContextSummary;
|
||||
existingUseCases: string[];
|
||||
model: string;
|
||||
maxPromptContexts: number;
|
||||
readCache: (key: string) => Promise<UsageDescription[] | null>;
|
||||
writeCache: (key: string, value: UsageDescription[]) => Promise<void>;
|
||||
};
|
||||
|
||||
const METADATA_INSTRUCTIONS = new URL(
|
||||
'../.github/instructions/metadata.instructions.md',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const FALLBACK_USE_CASE_GUIDELINES = `Write each use-case as a short phrase of roughly 4-12 words starting with a present participle verb (e.g. "Indicating a low battery charge level"), from the interface's perspective, naming the concrete context where useful. No trailing period, no first person, no marketing language, and never just the icon's name.`;
|
||||
|
||||
/**
|
||||
* Returns the "Use Cases" section of the repository's metadata instructions
|
||||
* (including its examples), so generated use-cases follow the same style.
|
||||
*/
|
||||
const loadUseCaseGuidelines = async () => {
|
||||
try {
|
||||
const fs = await import('node:fs/promises');
|
||||
const instructions = await fs.readFile(METADATA_INSTRUCTIONS, 'utf8');
|
||||
const start = instructions.indexOf('## Use Cases');
|
||||
if (start === -1) return FALLBACK_USE_CASE_GUIDELINES;
|
||||
const sections = instructions.slice(start).split(/\n(?=## )/);
|
||||
return sections
|
||||
.filter((section, index) => index === 0 || section.startsWith('## Examples'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
} catch {
|
||||
return FALLBACK_USE_CASE_GUIDELINES;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks an OpenAI model to cluster the collected usage contexts into distinct,
|
||||
* real-world use cases for the icon. Responses are cached by prompt hash.
|
||||
*/
|
||||
export const describeIconUsage = async ({
|
||||
icon,
|
||||
matches,
|
||||
summary,
|
||||
existingUseCases,
|
||||
model,
|
||||
maxPromptContexts,
|
||||
readCache,
|
||||
writeCache,
|
||||
}: DescribeOptions): Promise<UsageDescription[]> => {
|
||||
if (matches.length === 0) return [];
|
||||
const samples = matches.slice(0, maxPromptContexts).map((match, index) => ({
|
||||
id: index + 1,
|
||||
repository: match.repository,
|
||||
path: match.path,
|
||||
component: match.context.component,
|
||||
parents: match.context.ancestors,
|
||||
labels: [...match.context.labels, ...match.context.i18nKeys],
|
||||
handlers: match.context.handlers,
|
||||
links: match.context.links,
|
||||
snippet: match.context.snippet,
|
||||
}));
|
||||
|
||||
const input = `You are maintaining the metadata for the Lucide icon library. Below are real usages of the \`${icon}\` icon found in public GitHub repositories, extracted from source code.
|
||||
|
||||
Group these usages by what the icon means in them, and describe each group.
|
||||
|
||||
Fields for each group:
|
||||
- useCase: an array of 1-3 use-case phrases for this group, written exactly as the use-case guidelines below describe. Add more than one phrase only when the group covers genuinely distinct uses; never rephrase the same idea. Don't repeat a phrase across groups.
|
||||
- description: one or two sentences describing how and where the icon appears in the UI (e.g. inside a ghost button in a table row, next to a "Delete" label).
|
||||
- sampleShare: the approximate fraction (0-1) of the samples that belong to this group.
|
||||
- examples: ids of up to 3 samples that best illustrate the group.
|
||||
|
||||
Rules:
|
||||
- Return between 1 and 4 groups, most common first. Ignore samples without enough context to judge.
|
||||
- The samples are your source material: extract the function the icon serves in the interface and discard implementation details such as component names, class names or file paths.
|
||||
- Base everything on the samples; never invent use cases that are not supported by them.
|
||||
- Match the style of the existing use-cases, and avoid duplicating them.
|
||||
|
||||
Use-case guidelines (from .github/instructions/metadata.instructions.md):
|
||||
<guidelines>
|
||||
${await loadUseCaseGuidelines()}
|
||||
</guidelines>
|
||||
|
||||
Existing use-cases for "${icon}": ${JSON.stringify(existingUseCases)}
|
||||
|
||||
Aggregated signals:
|
||||
${JSON.stringify({ topLabels: summary.topLabels, topParents: summary.topAncestors, topHandlers: summary.topHandlers, kinds: summary.kinds })}
|
||||
|
||||
Samples:
|
||||
${JSON.stringify(samples, null, 1)}`;
|
||||
|
||||
const crypto = await import('node:crypto');
|
||||
const cacheKey = crypto.createHash('sha256').update(`${model}\n${input}`).digest('hex');
|
||||
const cached = await readCache(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const [{ default: OpenAI }, { zodTextFormat }, { default: z }] = await Promise.all([
|
||||
import('openai'),
|
||||
import('openai/helpers/zod'),
|
||||
import('zod'),
|
||||
]);
|
||||
const schema = z.object({
|
||||
useCases: z.array(
|
||||
z.object({
|
||||
useCase: z.array(z.string()),
|
||||
description: z.string(),
|
||||
sampleShare: z.number(),
|
||||
examples: z.array(z.number()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// The SDK retries timeouts (408), rate limits and server errors with backoff.
|
||||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, maxRetries: 5 });
|
||||
const response = await client.responses.create({
|
||||
model,
|
||||
input,
|
||||
text: { format: zodTextFormat(schema, 'usage') },
|
||||
});
|
||||
const parsed = schema.parse(JSON.parse(response.output_text));
|
||||
const descriptions = parsed.useCases.map((useCase) => ({
|
||||
useCase: useCase.useCase,
|
||||
description: useCase.description,
|
||||
sampleShare: useCase.sampleShare,
|
||||
examples: useCase.examples
|
||||
.map((id) => matches[id - 1])
|
||||
.filter(Boolean)
|
||||
.map((match) => `${match.htmlUrl}#L${match.context.line}`),
|
||||
}));
|
||||
await writeCache(cacheKey, descriptions);
|
||||
return descriptions;
|
||||
};
|
||||