2026-08-31 17:54:23 +02:00
import 'dotenv/config' ;
import crypto from 'node:crypto' ;
import fs from 'node:fs/promises' ;
import path from 'node:path' ;
2026-10-09 06:41:45 +00:00
import { fileURLToPath } from 'node:url' ;
import {
describeIconUsage ,
extractContexts ,
summarizeContexts ,
type ContextMatch ,
type IconContextSummary ,
type UsageContext ,
type UsageDescription ,
} from './iconUsageContext.mts' ;
2026-08-31 17:54:23 +02:00
const VERSION = '0.1.0' ;
const CACHE_DIR = path . resolve ( process . cwd (), '.cache/lucide-icon-usage' );
const SEARCH_RESULT_CAP = 1000 ;
const PER_PAGE = 100 ;
const MIN_SEARCH_DELAY_MS = 6500 ;
const REQUEST_TIMEOUT_MS = 30000 ;
2026-10-09 09:38:14 +00:00
const MAX_REQUEST_ATTEMPTS = 6 ;
const RETRYABLE_STATUSES = [ 403 , 408 , 429 , 500 , 502 , 503 , 504 ];
2026-08-31 17:54:23 +02:00
const DEFAULT_MAX_PAGES = 2 ;
const DEFAULT_MAX_FILES_PER_QUERY = 200 ;
const THOROUGH_MAX_PAGES = 10 ;
const THOROUGH_MAX_FILES_PER_QUERY = 1000 ;
2026-10-09 06:41:45 +00:00
const DEFAULT_MAX_CONTEXTS = 40 ;
const MAX_CONTEXTS_PER_REPOSITORY = 2 ;
2026-10-09 12:05:24 +02:00
const DEFAULT_DESCRIBE_MODEL = 'gpt-6-luna' ;
2026-10-09 06:41:45 +00:00
const ICONS_DIR = path . resolve ( path . dirname ( fileURLToPath ( import . meta . url )), '../icons' );
2026-08-31 17:54:23 +02:00
const SUPPORTED_PACKAGES = [
'lucide-react' ,
'@lucide/vue' ,
'@lucide/svelte' ,
'@lucide/angular' ,
'@lucide/astro' ,
'lucide-preact' ,
'lucide-solid' ,
'lucide' ,
'@lucide/lab' ,
] as const ;
type SupportedPackage = ( typeof SUPPORTED_PACKAGES )[ number ];
type FileScope = {
label : string ;
qualifier : string ;
extensions : string [];
};
const PACKAGE_FILE_SCOPES : Record < SupportedPackage , FileScope [] > = {
'lucide-react' : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
'@lucide/vue' : [
{ label : 'vue' , qualifier : 'extension:vue' , extensions : [ 'vue' ] },
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
'@lucide/svelte' : [
{ label : 'svelte' , qualifier : 'extension:svelte' , extensions : [ 'svelte' ] },
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
'@lucide/angular' : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' ] },
],
'@lucide/astro' : [
{ label : 'astro' , qualifier : 'extension:astro' , extensions : [ 'astro' ] },
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
'lucide-preact' : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
'lucide-solid' : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
],
lucide : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
{ label : 'html' , qualifier : 'extension:html' , extensions : [ 'html' ] },
],
'@lucide/lab' : [
{ label : 'typescript' , qualifier : 'language:typescript' , extensions : [ 'ts' , 'tsx' ] },
{
label : 'javascript' ,
qualifier : 'language:javascript' ,
extensions : [ 'js' , 'jsx' , 'mjs' , 'cjs' ],
},
{ label : 'vue' , qualifier : 'extension:vue' , extensions : [ 'vue' ] },
{ label : 'svelte' , qualifier : 'extension:svelte' , extensions : [ 'svelte' ] },
{ label : 'astro' , qualifier : 'extension:astro' , extensions : [ 'astro' ] },
{ label : 'html' , qualifier : 'extension:html' , extensions : [ 'html' ] },
],
};
const DIRECT_ICON_IMPORT_PACKAGES = new Set < SupportedPackage >([
'lucide-react' ,
'@lucide/vue' ,
'@lucide/svelte' ,
'@lucide/astro' ,
'lucide-preact' ,
'lucide-solid' ,
'lucide' ,
]);
type CliOptions = {
icons : string [];
packages : SupportedPackage [];
includeForks : boolean ;
includeArchived : boolean ;
includeGenerated : boolean ;
includeVendored : boolean ;
json : boolean ;
csv : boolean ;
refresh : boolean ;
verbose : boolean ;
maxPages : number ;
maxFilesPerQuery : number ;
tokenEnv : string ;
cacheDir : string ;
2026-10-09 06:41:45 +00:00
context : boolean ;
describe : boolean ;
maxContexts : number ;
model : string ;
iconsFiles : string [];
2026-10-09 07:00:21 +00:00
output : string | null ;
2026-08-31 17:54:23 +02:00
};
type SearchItem = {
name : string ;
path : string ;
url : string ;
html_url : string ;
repository : {
full_name : string ;
fork? : boolean ;
archived? : boolean ;
};
};
type SearchResponse = {
total_count : number ;
incomplete_results : boolean ;
items : SearchItem [];
};
type QueryRecord = {
icon : string ;
exportName : string ;
packageName : SupportedPackage ;
queryKind : string ;
extension : string ;
query : string ;
totalCount : number ;
fetchedItems : number ;
capped : boolean ;
incompleteResults : boolean ;
pagesFetched : number ;
cacheHits : number ;
localLimitReached : boolean ;
retrievableCapReached : boolean ;
};
type RepositoryRecord = {
icons : string [];
packages : SupportedPackage [];
matches : Array < {
icon : string ;
packageName : SupportedPackage ;
path : string ;
htmlUrl : string ;
evidence : string ;
2026-10-09 06:41:45 +00:00
contexts? : UsageContext [];
2026-08-31 17:54:23 +02:00
} > ;
};
2026-10-09 06:41:45 +00:00
type IconUsage = {
sampled : boolean ;
summary : IconContextSummary ;
descriptions? : UsageDescription [];
descriptionError? : string ;
};
2026-08-31 17:54:23 +02:00
type AnalysisResult = {
metadata : {
version : string ;
analyzedAt : string ;
gitCommit : string | null ;
githubApi : string ;
githubSearchMethod : string ;
resultCaps : string [];
packages : SupportedPackage [];
packageFileScopes : Record < SupportedPackage , FileScope [] >;
includeForks : boolean ;
includeArchived : boolean ;
includeGenerated : boolean ;
includeVendored : boolean ;
maxPages : number ;
maxFilesPerQuery : number ;
cacheDir : string ;
2026-10-09 06:41:45 +00:00
context : boolean ;
maxContexts : number | null ;
describeModel : string | null ;
2026-08-31 17:54:23 +02:00
queries : QueryRecord [];
warnings : string [];
biases : string [];
};
repositories : Record < string , RepositoryRecord >;
summary : ReturnType < typeof summarize >;
2026-10-09 06:41:45 +00:00
usage? : Record < string , IconUsage >;
2026-08-31 17:54:23 +02:00
};
const sleep = ( ms : number ) => new Promise (( resolve ) => setTimeout ( resolve , ms ));
const toPascalCase = ( icon : string ) =>
icon
. split ( /[-_\s]+/ )
. filter ( Boolean )
. map (( part ) => part . charAt ( 0 ). toUpperCase () + part . slice ( 1 ))
. join ( '' );
const toCamelCase = ( icon : string ) => {
const pascal = toPascalCase ( icon );
return pascal . charAt ( 0 ). toLowerCase () + pascal . slice ( 1 );
};
const exportNameForPackage = ( icon : string , packageName : SupportedPackage ) =>
packageName === '@lucide/angular'
? `Lucide ${ toPascalCase ( icon ) } `
: packageName === '@lucide/lab' || packageName === 'lucide'
? toCamelCase ( icon )
: toPascalCase ( icon );
const usageNamesForPackage = ( icon : string , packageName : SupportedPackage ) => {
const exportName = exportNameForPackage ( icon , packageName );
if ( packageName === '@lucide/angular' ) {
return [ exportName , `lucide ${ toPascalCase ( icon ) } ` ];
}
return [ exportName ];
};
const parseArgs = ( argv : string []) : CliOptions => {
let explicitPackages = false ;
const options : CliOptions = {
icons : [],
packages : [ 'lucide-react' ],
includeForks : false ,
includeArchived : false ,
includeGenerated : false ,
includeVendored : false ,
json : false ,
csv : false ,
refresh : false ,
verbose : false ,
maxPages : DEFAULT_MAX_PAGES ,
maxFilesPerQuery : DEFAULT_MAX_FILES_PER_QUERY ,
tokenEnv : 'GITHUB_TOKEN' ,
cacheDir : CACHE_DIR ,
2026-10-09 06:41:45 +00:00
context : false ,
describe : false ,
maxContexts : DEFAULT_MAX_CONTEXTS ,
model : process.env.OPENAI_MODEL ?? DEFAULT_DESCRIBE_MODEL ,
iconsFiles : [],
2026-10-09 07:00:21 +00:00
output : null ,
2026-08-31 17:54:23 +02:00
};
for ( let index = 0 ; index < argv . length ; index += 1 ) {
const arg = argv [ index ];
if ( arg === '--help' || arg === '-h' ) {
printHelp ();
process . exit ( 0 );
}
if ( arg === '--all-packages' ) {
options . packages = [... SUPPORTED_PACKAGES ];
explicitPackages = true ;
continue ;
}
if ( arg === '--package' ) {
const packageName = argv [ ++ index ] as SupportedPackage | undefined ;
if ( ! packageName || ! SUPPORTED_PACKAGES . includes ( packageName )) {
throw new Error ( `Unsupported package: ${ packageName ?? '(missing)' } ` );
}
if ( ! explicitPackages ) options . packages = [];
explicitPackages = true ;
options . packages = [... options . packages , packageName ];
continue ;
}
if ( arg . startsWith ( '--package=' )) {
const packageName = arg . slice ( '--package=' . length ) as SupportedPackage ;
if ( ! SUPPORTED_PACKAGES . includes ( packageName )) {
throw new Error ( `Unsupported package: ${ packageName } ` );
}
if ( ! explicitPackages ) options . packages = [];
explicitPackages = true ;
options . packages = [... options . packages , packageName ];
continue ;
}
if ( arg === '--include-forks' ) {
options . includeForks = true ;
continue ;
}
if ( arg === '--include-archived' ) {
options . includeArchived = true ;
continue ;
}
if ( arg === '--include-generated' ) {
options . includeGenerated = true ;
continue ;
}
if ( arg === '--include-vendored' ) {
options . includeVendored = true ;
continue ;
}
if ( arg === '--json' ) {
options . json = true ;
continue ;
}
if ( arg === '--csv' ) {
options . csv = true ;
continue ;
}
if ( arg === '--refresh' ) {
options . refresh = true ;
continue ;
}
if ( arg === '--thorough' ) {
options . maxPages = THOROUGH_MAX_PAGES ;
options . maxFilesPerQuery = THOROUGH_MAX_FILES_PER_QUERY ;
continue ;
}
if ( arg === '--max-pages' ) {
options . maxPages = parsePositiveInteger ( argv [ ++ index ], '--max-pages' );
continue ;
}
if ( arg . startsWith ( '--max-pages=' )) {
options . maxPages = parsePositiveInteger ( arg . slice ( '--max-pages=' . length ), '--max-pages' );
continue ;
}
if ( arg === '--max-files-per-query' ) {
options . maxFilesPerQuery = parsePositiveInteger ( argv [ ++ index ], '--max-files-per-query' );
continue ;
}
if ( arg . startsWith ( '--max-files-per-query=' )) {
options . maxFilesPerQuery = parsePositiveInteger (
arg . slice ( '--max-files-per-query=' . length ),
'--max-files-per-query' ,
);
continue ;
}
2026-10-09 06:41:45 +00:00
if ( arg === '--context' ) {
options . context = true ;
continue ;
}
if ( arg === '--describe' ) {
options . context = true ;
options . describe = true ;
continue ;
}
if ( arg === '--max-contexts' ) {
options . maxContexts = parsePositiveInteger ( argv [ ++ index ], '--max-contexts' );
continue ;
}
if ( arg . startsWith ( '--max-contexts=' )) {
options . maxContexts = parsePositiveInteger (
arg . slice ( '--max-contexts=' . length ),
'--max-contexts' ,
);
continue ;
}
if ( arg === '--model' ) {
options . model = argv [ ++ index ] ?? options . model ;
continue ;
}
if ( arg . startsWith ( '--model=' )) {
options . model = arg . slice ( '--model=' . length );
continue ;
}
2026-10-09 07:00:21 +00:00
if ( arg === '--output' || arg === '-o' ) {
const file = argv [ ++ index ];
if ( ! file ) throw new Error ( '--output requires a path.' );
options . output = path . resolve ( file );
options . context = true ;
continue ;
}
if ( arg . startsWith ( '--output=' )) {
options . output = path . resolve ( arg . slice ( '--output=' . length ));
options . context = true ;
continue ;
}
2026-10-09 06:41:45 +00:00
if ( arg === '--icons-file' ) {
const file = argv [ ++ index ];
if ( ! file ) throw new Error ( '--icons-file requires a path.' );
options . iconsFiles . push ( path . resolve ( file ));
continue ;
}
if ( arg . startsWith ( '--icons-file=' )) {
options . iconsFiles . push ( path . resolve ( arg . slice ( '--icons-file=' . length )));
continue ;
}
2026-08-31 17:54:23 +02:00
if ( arg === '--verbose' || arg === '-v' ) {
options . verbose = true ;
continue ;
}
if ( arg === '--token-env' ) {
options . tokenEnv = argv [ ++ index ] ?? options . tokenEnv ;
continue ;
}
if ( arg . startsWith ( '--token-env=' )) {
options . tokenEnv = arg . slice ( '--token-env=' . length );
continue ;
}
if ( arg === '--cache-dir' ) {
options . cacheDir = path . resolve ( argv [ ++ index ] ?? options . cacheDir );
continue ;
}
if ( arg . startsWith ( '--cache-dir=' )) {
options . cacheDir = path . resolve ( arg . slice ( '--cache-dir=' . length ));
continue ;
}
if ( arg . startsWith ( '-' )) {
throw new Error ( `Unknown option: ${ arg } ` );
}
options . icons . push ( arg );
}
options . icons = [... new Set ( options . icons . map (( icon ) => icon . trim ()). filter ( Boolean ))];
options . packages = [... new Set ( options . packages )];
2026-10-09 06:41:45 +00:00
if ( options . icons . length === 0 && options . iconsFiles . length === 0 ) {
2026-08-31 17:54:23 +02:00
throw new Error (
2026-10-09 06:41:45 +00:00
'Pass at least one Lucide icon name, for example: pnpm icon-usage trash trash-2' ,
2026-08-31 17:54:23 +02:00
);
}
2026-10-09 06:41:45 +00:00
if ( options . describe && ! process . env . OPENAI_API_KEY ) {
throw new Error ( '--describe requires OPENAI_API_KEY to be set.' );
}
2026-08-31 17:54:23 +02:00
return options ;
};
2026-10-09 06:41:45 +00:00
const knownIconNames = async () => {
try {
return ( await fs . readdir ( ICONS_DIR ))
. filter (( file ) => file . endsWith ( '.json' ))
. map (( file ) => path . basename ( file , '.json' ));
} catch {
return [];
}
};
/**
* Accepts kebab-case icon names (`trash-2`) as well as export names such as
* `Trash2`, `Trash2Icon` or `LucideTrash2`, as found in package export lists.
*/
const resolveIconNames = async ( inputs : string []) => {
const known = await knownIconNames ();
const knownSet = new Set ( known );
const byExportName = new Map ( known . map (( icon ) => [ toPascalCase ( icon ), icon ]));
const unresolved : string [] = [];
const icons = inputs . map (( input ) => {
if ( knownSet . has ( input ) || /^[a-z0-9-]+$/ . test ( input )) return input ;
const exportName = input . replace ( /^Lucide(?=[A-Z])/ , '' ). replace ( /Icon$/ , '' );
const icon = byExportName . get ( exportName ) ?? byExportName . get ( input );
if ( icon ) return icon ;
unresolved . push ( input );
return input
. replace ( /([a-z])([A-Z0-9])/g , '$1-$2' )
. replace ( /([0-9])([A-Z])/g , '$1-$2' )
. toLowerCase ();
});
if ( unresolved . length > 0 ) {
status (
` ${ unresolved . length } export name(s) not found in icons/, converted to kebab-case: ${ unresolved . slice ( 0 , 10 ). join ( ', ' ) }${ unresolved . length > 10 ? ', …' : '' } ` ,
);
}
return [... new Set ( icons )];
};
const readIconsFiles = async ( files : string []) =>
( await Promise . all ( files . map (( file ) => fs . readFile ( file , 'utf8' )))). flatMap (( content ) =>
content
. split ( /\r?\n/ )
. map (( line ) => line . replace ( /#.*$/ , '' ). trim ())
. filter ( Boolean ),
);
2026-08-31 17:54:23 +02:00
const parsePositiveInteger = ( value : string | undefined , option : string ) => {
const parsed = Number ( value );
if ( ! Number . isInteger ( parsed ) || parsed < 1 ) {
throw new Error ( ` ${ option } must be a positive integer.` );
}
return parsed ;
};
const printHelp = () => {
console . log ( `Lucide icon usage estimator
Usage:
pnpm icon-usage trash trash-2 [options]
Options:
--package <name> Search one supported package; repeat for several
--all-packages Search all supported packages
--include-forks Include repositories GitHub marks as forks
--include-archived Include archived repositories
--include-generated Include files GitHub marks as generated
--include-vendored Include files GitHub marks as vendored
--json Print JSON including repository-to-icon mapping
--csv Print repository CSV
--refresh Ignore cached GitHub responses
--max-pages <n> Max pages per query slice (default: ${ DEFAULT_MAX_PAGES } )
--max-files-per-query <n>
Max candidate files to validate per query slice (default: ${ DEFAULT_MAX_FILES_PER_QUERY } )
--thorough Shortcut for --max-pages ${ THOROUGH_MAX_PAGES } --max-files-per-query ${ THOROUGH_MAX_FILES_PER_QUERY }
2026-10-09 07:00:21 +00:00
--output, -o <path> Write usage per icon to a JSON file as { [icon]: [...usage] }:
use cases with --describe, otherwise individual usages
(implies --context)
2026-10-09 06:41:45 +00:00
--icons-file <path> Read icon names (one per line, kebab-case or export names
like Trash2) from a file; repeatable
--context Extract how each icon is used in context (parent element,
labels, handlers, snippet) from the validated files
--describe Like --context, then ask an OpenAI model to group the
contexts into use-case descriptions (needs OPENAI_API_KEY)
--max-contexts <n> Stop searching an icon once n contexts are collected when
--context/--describe is set (default: ${ DEFAULT_MAX_CONTEXTS } )
--model <name> Model for --describe (default: $OPENAI_MODEL or ${ DEFAULT_DESCRIBE_MODEL } )
2026-08-31 17:54:23 +02:00
--verbose, -v Print detailed progress logs on stderr
--token-env <name> Token environment variable (default: GITHUB_TOKEN)
--cache-dir <path> Cache directory (default: .cache/lucide-icon-usage)
Supported packages:
${ SUPPORTED_PACKAGES . join ( ', ' ) }
Default package:
lucide-react
` );
};
const stableHash = ( value : string ) => crypto . createHash ( 'sha256' ). update ( value ). digest ( 'hex' );
const progress = ( options : Pick < CliOptions , 'verbose' >, message : string ) => {
if ( options . verbose ) console . error ( `[icon-usage] ${ message } ` );
};
const status = ( message : string ) => {
console . error ( `[icon-usage] ${ message } ` );
};
const readCachedJson = async < T ,>( cacheFile : string ) : Promise < T | null > => {
try {
return JSON . parse ( await fs . readFile ( cacheFile , 'utf8' )) as T ;
} catch ( error ) {
if (( error as NodeJS . ErrnoException ). code === 'ENOENT' ) return null ;
throw error ;
}
};
const writeCachedJson = async ( cacheFile : string , value : unknown ) => {
await fs . mkdir ( path . dirname ( cacheFile ), { recursive : true });
await fs . writeFile ( cacheFile , ` ${ JSON . stringify ( value , null , 2 ) } \ n` );
};
class GitHubClient {
# token : string ;
# cacheDir : string ;
# refresh : boolean ;
# verbose : boolean ;
# lastSearchAt = 0 ;
constructor ( token : string , cacheDir : string , refresh : boolean , verbose : boolean ) {
this . # token = token ;
this . # cacheDir = cacheDir ;
this . # refresh = refresh ;
this . # verbose = verbose ;
}
async searchCode (
query : string ,
page : number ,
) : Promise < { response : SearchResponse ; cacheHit : boolean } > {
const url = new URL ( 'https://api.github.com/search/code' );
url . searchParams . set ( 'q' , query );
url . searchParams . set ( 'per_page' , String ( PER_PAGE ));
url . searchParams . set ( 'page' , String ( page ));
return this . # cachedFetch < SearchResponse >( url . toString (), 'search' , true );
}
async getJson < T >( url : string ) : Promise < { response : T ; cacheHit : boolean } > {
return this . # cachedFetch < T >( url , 'contents' , false );
}
async # cachedFetch < T >(
url : string ,
namespace : string ,
searchEndpoint : boolean ,
) : Promise < { response : T ; cacheHit : boolean } > {
const cacheFile = path . join ( this . # cacheDir , namespace , ` ${ stableHash ( url ) } .json` );
if ( ! this . # refresh ) {
const cached = await readCachedJson < T >( cacheFile );
if ( cached ) {
progress ({ verbose : this. # verbose }, `cache hit: ${ namespace } ${ url } ` );
return { response : cached , cacheHit : true };
}
}
progress ({ verbose : this. # verbose }, `fetching: ${ namespace } ${ url } ` );
const response = await this . # fetchJson < T >( url , searchEndpoint );
await writeCachedJson ( cacheFile , response );
return { response , cacheHit : false };
}
async # fetchJson < T >( url : string , searchEndpoint : boolean ) : Promise < T > {
2026-10-09 09:38:14 +00:00
for ( let attempt = 0 ; attempt < MAX_REQUEST_ATTEMPTS ; attempt += 1 ) {
const canRetry = attempt < MAX_REQUEST_ATTEMPTS - 1 ;
2026-08-31 17:54:23 +02:00
if ( searchEndpoint ) {
const elapsed = Date . now () - this . # lastSearchAt ;
if ( elapsed < MIN_SEARCH_DELAY_MS ) await sleep ( MIN_SEARCH_DELAY_MS - elapsed );
this . # lastSearchAt = Date . now ();
}
let response : Response ;
const controller = new AbortController ();
const timeout = setTimeout (() => controller . abort (), REQUEST_TIMEOUT_MS );
try {
response = await fetch ( url , {
signal : controller.signal ,
headers : {
Accept : 'application/vnd.github+json' ,
Authorization : `Bearer ${ this . # token } ` ,
'X-GitHub-Api-Version' : '2022-11-28' ,
'User-Agent' : 'lucide-icon-usage-script' ,
},
});
} catch ( error ) {
2026-10-09 09:38:14 +00:00
if ( canRetry ) {
2026-08-31 17:54:23 +02:00
const waitMs = 2 ** attempt * 3000 ;
2026-10-09 09:38:14 +00:00
status (
`network error on attempt ${ attempt + 1 } / ${ MAX_REQUEST_ATTEMPTS } ; retrying in ${ Math . round ( waitMs / 1000 ) } s: ${
2026-08-31 17:54:23 +02:00
( error as Error ). message
} ` ,
);
await sleep ( waitMs );
continue ;
}
throw new Error (
`GitHub request failed before receiving a response for ${ url } : ${ ( error as Error ). message } ` ,
);
} finally {
clearTimeout ( timeout );
}
if ( response . ok ) return response . json () as Promise < T >;
const retryAfter = Number ( response . headers . get ( 'retry-after' ));
const reset = Number ( response . headers . get ( 'x-ratelimit-reset' ));
const body = await response . text ();
2026-10-09 09:38:14 +00:00
if ( RETRYABLE_STATUSES . includes ( response . status ) && canRetry ) {
2026-08-31 17:54:23 +02:00
const waitMs =
Number . isFinite ( retryAfter ) && retryAfter > 0
? retryAfter * 1000
2026-10-09 09:38:14 +00:00
: [ 403 , 429 ]. includes ( response . status ) &&
Number . isFinite ( reset ) &&
reset > Date . now () / 1000
2026-08-31 17:54:23 +02:00
? reset * 1000 - Date . now () + 1000
: 2 ** attempt * 3000 ;
const boundedWaitMs = Math . min ( Math . max ( waitMs , 1000 ), 120000 );
2026-10-09 09:38:14 +00:00
status (
`GitHub returned ${ response . status } on attempt ${ attempt + 1 } / ${ MAX_REQUEST_ATTEMPTS } ; retrying in ${ Math . round (
2026-08-31 17:54:23 +02:00
boundedWaitMs / 1000 ,
) } s` ,
);
await sleep ( boundedWaitMs );
continue ;
}
throw new Error ( `GitHub request failed ( ${ response . status } ) for ${ url } : ${ body } ` );
}
throw new Error ( `GitHub request failed after retries for ${ url } ` );
}
}
const queryFilters = ( options : CliOptions ) =>
[
'in:file' ,
options . includeForks ? null : 'NOT is:fork' ,
options . includeArchived ? null : 'NOT is:archived' ,
options . includeGenerated ? null : 'NOT is:generated' ,
options . includeVendored ? null : 'NOT is:vendored' ,
]. filter ( Boolean ) as string [];
const queriesFor = ( icon : string , packageName : SupportedPackage , options : CliOptions ) => {
const exportName = exportNameForPackage ( icon , packageName );
const filters = queryFilters ( options );
return PACKAGE_FILE_SCOPES [ packageName ]. flatMap (( fileScope ) => {
const importHint = fileScope . extensions . includes ( 'html' ) ? null : 'import' ;
const namedImportQuery = [
`" ${ packageName } "` ,
exportName ,
importHint ,
fileScope . qualifier ,
... filters ,
]
. filter ( Boolean )
. join ( ' ' );
if ( ! DIRECT_ICON_IMPORT_PACKAGES . has ( packageName )) {
return [{ queryKind : 'named-import' , fileScope , query : namedImportQuery }];
}
return [
{ queryKind : 'named-import' , fileScope , query : namedImportQuery },
{
queryKind : 'direct-icon-import' ,
fileScope ,
query : [ `" ${ packageName } /icons/ ${ icon } "` , fileScope . qualifier , ... filters ]. join ( ' ' ),
},
];
});
};
const hasAllowedExtension = ( filePath : string , fileScope : FileScope ) =>
fileScope . extensions . some (( extension ) => filePath . toLowerCase (). endsWith ( `. ${ extension } ` ));
const fetchAllSearchResults = async (
client : GitHubClient ,
icon : string ,
packageName : SupportedPackage ,
queryKind : string ,
fileScope : FileScope ,
query : string ,
options : CliOptions ,
) : Promise < { items : SearchItem []; record : QueryRecord } > => {
const items : SearchItem [] = [];
let totalCount = 0 ;
let incompleteResults = false ;
let cacheHits = 0 ;
let pagesFetched = 0 ;
progress (
options ,
`search start: icon= ${ icon } package= ${ packageName } kind= ${ queryKind } scope= ${ fileScope . label } ` ,
);
progress ( options , `query: ${ query } ` );
for ( let page = 1 ; page <= options . maxPages ; page += 1 ) {
progress (
options ,
`search page ${ page } / ${ options . maxPages } : icon= ${ icon } package= ${ packageName } kind= ${ queryKind } scope= ${ fileScope . label } ` ,
);
const { response , cacheHit } = await client . searchCode ( query , page );
pagesFetched += 1 ;
if ( cacheHit ) cacheHits += 1 ;
totalCount = response . total_count ;
incompleteResults = incompleteResults || response . incomplete_results ;
const matchingItems = response . items . filter (( item ) =>
hasAllowedExtension ( item . path , fileScope ),
);
items . push (... matchingItems . slice ( 0 , Math . max ( options . maxFilesPerQuery - items . length , 0 )));
progress (
options ,
`page ${ page } done: ${ response . items . length } items ( ${ matchingItems . length } matching ${ fileScope . label } ), ${ items . length } fetched, total_count= ${ totalCount . toLocaleString (
'en-US' ,
) } , incomplete= ${ response . incomplete_results } , cache= ${ cacheHit ? 'hit' : 'miss' } ` ,
);
if (
response . items . length < PER_PAGE ||
items . length >= SEARCH_RESULT_CAP ||
items . length >= options . maxFilesPerQuery
)
break ;
}
progress (
options ,
`search done: icon= ${ icon } package= ${ packageName } kind= ${ queryKind } scope= ${ fileScope . label } ; fetched= ${ items . length } , total_count= ${ totalCount . toLocaleString (
'en-US' ,
) }${ totalCount > SEARCH_RESULT_CAP || items . length >= SEARCH_RESULT_CAP ? ' (GITHUB CAPPED)' : '' }${ items . length >= options . maxFilesPerQuery && totalCount > items . length ? ' (LOCAL LIMIT)' : '' } ` ,
);
return {
items ,
record : {
icon ,
exportName : exportNameForPackage ( icon , packageName ),
packageName ,
queryKind ,
extension : fileScope.label ,
query ,
totalCount ,
fetchedItems : items.length ,
capped : totalCount > SEARCH_RESULT_CAP || items . length >= SEARCH_RESULT_CAP ,
incompleteResults ,
pagesFetched ,
cacheHits ,
localLimitReached : items.length >= options . maxFilesPerQuery && totalCount > items . length ,
retrievableCapReached : totalCount > SEARCH_RESULT_CAP || items . length >= SEARCH_RESULT_CAP ,
},
};
};
const decodeContent = ( payload : { content? : string ; encoding? : string }) => {
if ( payload . encoding !== 'base64' || ! payload . content ) return '' ;
return Buffer . from ( payload . content . replace ( /\n/g , '' ), 'base64' ). toString ( 'utf8' );
};
const escapeRegExp = ( value : string ) => value . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
const hasNamedImport = ( source : string , packageName : SupportedPackage , icon : string ) => {
const names = usageNamesForPackage ( icon , packageName ). map ( escapeRegExp ). join ( '|' );
const importRegex = new RegExp (
`import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+['"] ${ escapeRegExp ( packageName ) } ['"]` ,
'g' ,
);
for ( const match of source . matchAll ( importRegex )) {
const specifiers = match [ 1 ] ?? '' ;
if (
new RegExp (
`(^|,)\\s*(?:type\\s+)?( ${ names } )(?:\\s+as\\s+[A-Za-z_$][\\w$]*)?\\s*(?=,|$)` ,
). test ( specifiers )
) {
return true ;
}
}
return false ;
};
const hasDirectImport = ( source : string , packageName : SupportedPackage , icon : string ) => {
if ( packageName === '@lucide/lab' ) return false ;
return (
new RegExp ( `from\\s+['"] ${ escapeRegExp ( ` ${ packageName } /icons/ ${ icon } ` ) } ['"]` ). test ( source ) ||
new RegExp (
`import\\s*\\(\\s*['"] ${ escapeRegExp ( ` ${ packageName } /icons/ ${ icon } ` ) } ['"]\\s*\\)` ,
). test ( source )
);
};
const hasCommonUsage = ( source : string , packageName : SupportedPackage , icon : string ) => {
const names = usageNamesForPackage ( icon , packageName ). map ( escapeRegExp ). join ( '|' );
if ( hasNamedImport ( source , packageName , icon )) return 'named import' ;
if ( hasDirectImport ( source , packageName , icon )) return 'direct icon import' ;
if ( packageName === 'lucide' && new RegExp ( `\\b(?:icons\\.)? ${ names } \\b` ). test ( source ))
return 'lucide export reference' ;
if ( packageName === '@lucide/angular' && new RegExp ( `\\b ${ names } \\b` ). test ( source ))
return 'angular icon reference' ;
if ( packageName === '@lucide/lab' && hasNamedImport ( source , packageName , icon ))
return 'lab named import' ;
return null ;
};
const analyze = async ( options : CliOptions ) : Promise < AnalysisResult > => {
const token = process . env [ options . tokenEnv ];
if ( ! token ) {
throw new Error (
`GitHub code search requires authentication. Set ${ options . tokenEnv } or pass --token-env <name>.` ,
);
}
const client = new GitHubClient ( token , options . cacheDir , options . refresh , options . verbose );
const repositories = new Map < string , RepositoryRecord >();
const queries : QueryRecord [] = [];
const seenFiles = new Set < string >();
const candidateRepositories = new Set < string >();
const candidatePackageRepositories = new Map < SupportedPackage , Set < string >>(
options . packages . map (( packageName ) => [ packageName , new Set < string >()]),
);
const querySlices = options . icons . reduce (
( count , icon ) =>
count +
options . packages . reduce (
( sum , packageName ) => sum + queriesFor ( icon , packageName , options ). length ,
0 ,
),
0 ,
);
let completedQuerySlices = 0 ;
let contentFilesChecked = 0 ;
2026-10-09 06:41:45 +00:00
const contextMatches = new Map < string , ContextMatch [] >(
options . icons . map (( icon ) => [ icon , [] as ContextMatch []]),
);
const contextLimitReached = ( icon : string ) =>
options . context && ( contextMatches . get ( icon ) ? . length ?? 0 ) >= options . maxContexts ;
const sampledIcons = new Set < string >();
2026-10-09 09:38:14 +00:00
const failedRequests : string [] = [];
const usage : Record < string , IconUsage > = {};
const completedIcons : string [] = [];
2026-08-31 17:54:23 +02:00
status (
`starting: icons= ${ options . icons . join ( ', ' ) } packages= ${ options . packages . join ( ', ' ) } query-slices= ${ querySlices } max-pages= ${ options . maxPages } max-files-per-query= ${ options . maxFilesPerQuery } ` ,
);
progress ( options , `starting analysis for icons: ${ options . icons . join ( ', ' ) } ` );
progress ( options , `packages: ${ options . packages . join ( ', ' ) } ` );
progress ( options , `query slices planned: ${ querySlices } ` );
progress (
options ,
`cache directory: ${ options . cacheDir }${ options . refresh ? ' (refresh enabled)' : '' } ` ,
);
for ( const icon of options . icons ) {
for ( const packageName of options . packages ) {
for ( const { queryKind , fileScope , query } of queriesFor ( icon , packageName , options )) {
2026-10-09 06:41:45 +00:00
if ( contextLimitReached ( icon )) {
sampledIcons . add ( icon );
completedQuerySlices += 1 ;
progress (
options ,
`skipping ${ icon } ${ packageName } ${ fileScope . label } ${ queryKind } : --max-contexts reached` ,
);
continue ;
}
2026-10-09 09:38:14 +00:00
let searchResult : Awaited < ReturnType < typeof fetchAllSearchResults >>;
try {
searchResult = await fetchAllSearchResults (
client ,
icon ,
packageName ,
queryKind ,
fileScope ,
query ,
options ,
);
} catch ( error ) {
completedQuerySlices += 1 ;
failedRequests . push ( `search ${ icon } ${ packageName } ${ fileScope . label } ${ queryKind } ` );
status (
`search ${ completedQuerySlices } / ${ querySlices } failed, skipping: ${ icon } ${ packageName } ${ fileScope . label } ${ queryKind } : ${ ( error as Error ). message } ` ,
);
continue ;
}
const { items , record } = searchResult ;
2026-08-31 17:54:23 +02:00
queries . push ( record );
completedQuerySlices += 1 ;
status (
`search ${ completedQuerySlices } / ${ querySlices } : ${ icon } ${ packageName } ${ fileScope . label } ${ queryKind } ; fetched= ${ items . length } / ${ record . totalCount . toLocaleString ( 'en-US' ) }${ record . retrievableCapReached ? ' GitHub-capped' : '' }${ record . localLimitReached ? ' local-limit' : '' } ` ,
);
progress ( options , `query slice progress: ${ completedQuerySlices } / ${ querySlices } ` );
for ( const [ itemIndex , item ] of items . entries ()) {
2026-10-09 06:41:45 +00:00
if ( contextLimitReached ( icon )) {
sampledIcons . add ( icon );
break ;
}
2026-08-31 17:54:23 +02:00
const repo = item . repository . full_name ;
candidateRepositories . add ( repo );
candidatePackageRepositories . get ( packageName ) ? . add ( repo );
const fileKey = ` ${ item . url } # ${ icon } # ${ packageName } ` ;
if ( seenFiles . has ( fileKey )) continue ;
seenFiles . add ( fileKey );
contentFilesChecked += 1 ;
if ( ! options . verbose && contentFilesChecked % 100 === 0 ) {
status (
`validated ${ contentFilesChecked } candidate files; matched repositories= ${ repositories . size } ` ,
);
}
if ( contentFilesChecked === 1 || contentFilesChecked % 25 === 0 ) {
progress (
options ,
`validating file content: checked= ${ contentFilesChecked } , candidates= ${ candidateRepositories . size } , matches= ${ repositories . size } , current= ${ itemIndex + 1 } / ${ items . length } ${ repo } / ${ item . path } ` ,
);
}
2026-10-09 09:38:14 +00:00
let contentPayload : { content? : string ; encoding? : string };
try {
({ response : contentPayload } = await client . getJson < {
content? : string ;
encoding? : string ;
} > ( item . url ));
} catch ( error ) {
failedRequests . push ( `contents ${ repo } / ${ item . path } ` );
status ( `fetching ${ repo } / ${ item . path } failed, skipping: ${ ( error as Error ). message } ` );
continue ;
}
2026-08-31 17:54:23 +02:00
const source = decodeContent ( contentPayload );
const evidence = hasCommonUsage ( source , packageName , icon );
if ( ! evidence ) continue ;
const recordForRepo = repositories . get ( repo ) ?? { icons : [], packages : [], matches : [] };
if ( ! recordForRepo . icons . includes ( icon )) recordForRepo . icons . push ( icon );
if ( ! recordForRepo . packages . includes ( packageName ))
recordForRepo . packages . push ( packageName );
2026-10-09 06:41:45 +00:00
const contexts = options . context
? extractContexts ( source , packageName , icon , usageNamesForPackage ( icon , packageName ))
: undefined ;
2026-08-31 17:54:23 +02:00
recordForRepo . matches . push ({
icon ,
packageName ,
path : item.path ,
htmlUrl : item.html_url ,
evidence ,
2026-10-09 06:41:45 +00:00
...( contexts ? { contexts } : {}),
2026-08-31 17:54:23 +02:00
});
2026-10-09 06:41:45 +00:00
if ( contexts ) {
// Cap contexts per repository so one large project cannot dominate the sample.
const iconContexts = contextMatches . get ( icon ) ?? [];
const fromRepo = iconContexts . filter (( match ) => match . repository === repo ). length ;
for ( const context of contexts . slice (
0 ,
Math . max ( 0 , MAX_CONTEXTS_PER_REPOSITORY - fromRepo ),
)) {
if ( iconContexts . length >= options . maxContexts ) break ;
iconContexts . push ({
repository : repo ,
path : item.path ,
htmlUrl : item.html_url ,
context ,
});
}
contextMatches . set ( icon , iconContexts );
progress (
options ,
`contexts: ${ icon } ${ iconContexts . length } / ${ options . maxContexts } ( ${ contexts . length } in ${ repo } / ${ item . path } )` ,
);
}
2026-08-31 17:54:23 +02:00
repositories . set ( repo , recordForRepo );
if ( ! options . verbose ) {
status ( `match: ${ repo } uses ${ icon } via ${ packageName } in ${ item . path } ` );
}
progress (
options ,
`match: ${ repo } uses ${ icon } via ${ packageName } ( ${ evidence } ) in ${ item . path } ; matched repositories= ${ repositories . size } ` ,
);
}
}
}
2026-10-09 09:38:14 +00:00
// Finish each icon before starting the next so a crash or interruption
// keeps the results gathered so far in --output.
if ( options . context ) {
usage [ icon ] = await buildIconUsage (
options ,
icon ,
contextMatches . get ( icon ) ?? [],
sampledIcons . has ( icon ),
);
}
completedIcons . push ( icon );
if ( options . output ) {
await writeOutput ( options . output , completedIcons , usage , repositories , options );
}
2026-08-31 17:54:23 +02:00
}
progress (
options ,
`analysis complete: candidate repositories= ${ candidateRepositories . size } , validated repositories= ${ repositories . size } , files checked= ${ contentFilesChecked } ` ,
);
status (
`complete: candidate repositories= ${ candidateRepositories . size } , matched repositories= ${ repositories . size } , files checked= ${ contentFilesChecked } ` ,
);
const repositoryObject = Object . fromEntries (
[... repositories . entries ()]
. sort (([ a ], [ b ]) => a . localeCompare ( b ))
. map (([ repo , record ]) => [
repo ,
{
... record ,
icons : record.icons.sort (),
packages : record.packages.sort (),
},
]),
);
const warnings = buildWarnings ( queries );
2026-10-09 06:41:45 +00:00
if ( sampledIcons . size > 0 ) {
warnings . push (
`--max-contexts ( ${ options . maxContexts } ) was reached for ${ [... sampledIcons ]. join ( ', ' ) } ; searching stopped early, so repository counts for those icons are a lower bound.` ,
);
}
2026-10-09 09:38:14 +00:00
if ( failedRequests . length > 0 ) {
warnings . push (
` ${ failedRequests . length } request(s) failed after ${ MAX_REQUEST_ATTEMPTS } attempts and were skipped, so results are incomplete: ${ failedRequests . slice ( 0 , 10 ). join ( '; ' ) }${ failedRequests . length > 10 ? '; …' : '' } . Successful responses are cached, so re-running retries only the failed requests.` ,
);
}
2026-08-31 17:54:23 +02:00
return {
metadata : {
version : VERSION ,
analyzedAt : new Date (). toISOString (),
gitCommit : await gitCommit (),
githubApi : 'GitHub REST API GET /search/code, API version 2022-11-28' ,
githubSearchMethod :
'Package-scoped code search queries, followed by file-content validation of common Lucide import styles, deduplicated by repository full_name.' ,
resultCaps : [
'GitHub REST code search returns at most 1,000 results per query.' ,
'GitHub REST code search is authenticated and rate-limited to 10 requests per minute.' ,
'Only default branches and files smaller than 384 KB are searchable.' ,
'Search can time out and return incomplete_results=true.' ,
'total_count is a search estimate and is not used as the repository count.' ,
],
packages : options.packages ,
packageFileScopes : Object.fromEntries (
options . packages . map (( packageName ) => [ packageName , PACKAGE_FILE_SCOPES [ packageName ]]),
) as Record < SupportedPackage , FileScope [] >,
includeForks : options.includeForks ,
includeArchived : options.includeArchived ,
includeGenerated : options.includeGenerated ,
includeVendored : options.includeVendored ,
maxPages : options.maxPages ,
maxFilesPerQuery : options.maxFilesPerQuery ,
cacheDir : options.cacheDir ,
2026-10-09 06:41:45 +00:00
context : options.context ,
maxContexts : options.context ? options.maxContexts : null ,
describeModel : options.describe ? options.model : null ,
2026-08-31 17:54:23 +02:00
queries ,
warnings ,
biases : [
'Only public GitHub repositories visible to the authenticated token are observable.' ,
'Private repositories and consumers outside GitHub are absent.' ,
'GitHub-hosted code is not representative of every Lucide consumer.' ,
'Package/framework usage may differ across ecosystems.' ,
'GitHub code indexing can be delayed, incomplete, or limited by file size/default branch.' ,
'Generated and vendored code may still create false positives when GitHub cannot identify it.' ,
'Very common icons can exceed search-result caps; capped queries should be treated as lower-bound or sampled evidence, not complete counts.' ,
],
},
repositories : repositoryObject ,
summary : summarize (
options . icons ,
options . packages ,
repositoryObject ,
candidateRepositories . size ,
candidatePackageRepositories ,
),
2026-10-09 09:38:14 +00:00
...( options . context ? { usage } : {}),
2026-08-31 17:54:23 +02:00
};
};
2026-10-09 06:41:45 +00:00
const readExistingUseCases = async ( icon : string ) => {
try {
const metadata = JSON . parse ( await fs . readFile ( path . join ( ICONS_DIR , ` ${ icon } .json` ), 'utf8' ));
return Array . isArray ( metadata [ 'use-cases' ]) ? ( metadata [ 'use-cases' ] as string []) : [];
} catch {
return [];
}
};
2026-10-09 09:38:14 +00:00
const buildIconUsage = async (
2026-10-09 06:41:45 +00:00
options : CliOptions ,
2026-10-09 09:38:14 +00:00
icon : string ,
matches : ContextMatch [],
sampled : boolean ,
) : Promise < IconUsage > => {
const summary = summarizeContexts ( matches );
const iconUsage : IconUsage = { sampled , summary };
if ( ! options . describe || matches . length === 0 ) return iconUsage ;
2026-10-09 06:41:45 +00:00
2026-10-09 09:38:14 +00:00
status ( `describing ${ icon } from ${ matches . length } contexts with ${ options . model } ` );
try {
iconUsage . descriptions = await describeIconUsage ({
icon ,
matches ,
summary ,
existingUseCases : await readExistingUseCases ( icon ),
model : options.model ,
maxPromptContexts : options.maxContexts ,
readCache : ( key ) =>
options . refresh
? Promise . resolve ( null )
: readCachedJson < UsageDescription [] >(
path . join ( options . cacheDir , 'describe' , ` ${ key } .json` ),
),
writeCache : ( key , value ) =>
writeCachedJson ( path . join ( options . cacheDir , 'describe' , ` ${ key } .json` ), value ),
});
} catch ( error ) {
iconUsage . descriptionError = ( error as Error ). message ;
status ( `describing ${ icon } failed: ${ ( error as Error ). message } ` );
2026-10-09 06:41:45 +00:00
}
2026-10-09 09:38:14 +00:00
return iconUsage ;
2026-10-09 06:41:45 +00:00
};
2026-08-31 17:54:23 +02:00
const gitCommit = async () => {
try {
const { execFile } = await import ( 'node:child_process' );
return await new Promise < string | null >(( resolve ) => {
execFile ( 'git' , [ 'rev-parse' , 'HEAD' ], ( error , stdout ) =>
resolve ( error ? null : stdout . trim ()),
);
});
} catch {
return null ;
}
};
const buildWarnings = ( queries : QueryRecord []) => {
const warnings : string [] = [];
const capped = queries . filter (( query ) => query . capped );
const incomplete = queries . filter (( query ) => query . incompleteResults );
const locallyLimited = queries . filter (( query ) => query . localLimitReached );
const pageLimited = queries . filter (
( query ) =>
query . pagesFetched >= 1 &&
query . pagesFetched < THOROUGH_MAX_PAGES &&
query . totalCount > query . pagesFetched * PER_PAGE ,
);
if ( capped . length > 0 ) {
warnings . push (
` ${ capped . length } query/queries hit or exceeded GitHub's 1,000-result retrieval cap. Counts are incomplete for those query slices.` ,
);
}
if ( locallyLimited . length > 0 ) {
warnings . push (
` ${ locallyLimited . length } query/queries reached --max-files-per-query before all fetched candidates were validated. Increase --max-files-per-query or use --thorough for a broader sample.` ,
);
}
if ( pageLimited . length > 0 ) {
warnings . push (
` ${ pageLimited . length } query/queries likely had additional pages beyond --max-pages. Increase --max-pages or use --thorough for a broader sample.` ,
);
}
if ( incomplete . length > 0 ) {
warnings . push (
` ${ incomplete . length } query/queries returned incomplete_results=true. GitHub may have timed out before searching all eligible code.` ,
);
}
warnings . push (
'For broad icons, prefer narrowing by package, language/path, or time/repository cohorts and cite the query metadata rather than treating the result as exhaustive.' ,
);
return warnings ;
};
const summarize = (
icons : string [],
packages : SupportedPackage [],
repositories : Record < string , RepositoryRecord >,
repositoriesSearched : number ,
candidatePackageRepositories : Map < SupportedPackage , Set < string >>,
) => {
const iconTotals = Object . fromEntries ( icons . map (( icon ) => [ icon , 0 ])) as Record < string , number >;
const exclusive = Object . fromEntries ( icons . map (( icon ) => [ icon , 0 ])) as Record < string , number >;
const combinations : Record < string , { icons : string []; count : number ; percentage : number }> = {};
const totalRepositories = Object . keys ( repositories ). length ;
for ( const record of Object . values ( repositories )) {
const matchedIcons = icons . filter (( icon ) => record . icons . includes ( icon ));
for ( const icon of matchedIcons ) iconTotals [ icon ] += 1 ;
if ( matchedIcons . length === 1 ) exclusive [ matchedIcons [ 0 ]] += 1 ;
const key = matchedIcons . join ( '+' );
combinations [ key ] ??= { icons : matchedIcons , count : 0 , percentage : 0 };
combinations [ key ]. count += 1 ;
}
for ( const combination of Object . values ( combinations )) {
combination . percentage =
totalRepositories === 0 ? 0 : ( combination . count / totalRepositories ) * 100 ;
}
return {
icons : Object.fromEntries (
icons . map (( icon ) => [
icon ,
{
uniqueRepositories : iconTotals [ icon ],
exclusive : exclusive [ icon ],
percentageOfTotal :
totalRepositories === 0 ? 0 : ( iconTotals [ icon ] / totalRepositories ) * 100 ,
exclusivePercentageOfTotal :
totalRepositories === 0 ? 0 : ( exclusive [ icon ] / totalRepositories ) * 100 ,
},
]),
),
combinations ,
packages : Object.fromEntries (
packages . map (( packageName ) => [
packageName ,
summarizePackage (
icons ,
repositories ,
packageName ,
candidatePackageRepositories . get ( packageName ) ? . size ?? 0 ,
),
]),
),
totalRepositories ,
repositoriesSearched ,
};
};
const summarizePackage = (
icons : string [],
repositories : Record < string , RepositoryRecord >,
packageName : SupportedPackage ,
repositoriesSearched : number ,
) => {
const iconTotals = Object . fromEntries ( icons . map (( icon ) => [ icon , 0 ])) as Record < string , number >;
const exclusive = Object . fromEntries ( icons . map (( icon ) => [ icon , 0 ])) as Record < string , number >;
const combinations : Record < string , { icons : string []; count : number ; percentage : number }> = {};
let totalRepositories = 0 ;
for ( const record of Object . values ( repositories )) {
const packageIcons = icons . filter (( icon ) =>
record . matches . some (( match ) => match . packageName === packageName && match . icon === icon ),
);
if ( packageIcons . length === 0 ) continue ;
totalRepositories += 1 ;
for ( const icon of packageIcons ) iconTotals [ icon ] += 1 ;
if ( packageIcons . length === 1 ) exclusive [ packageIcons [ 0 ]] += 1 ;
const key = packageIcons . join ( '+' );
combinations [ key ] ??= { icons : packageIcons , count : 0 , percentage : 0 };
combinations [ key ]. count += 1 ;
}
for ( const combination of Object . values ( combinations )) {
combination . percentage =
totalRepositories === 0 ? 0 : ( combination . count / totalRepositories ) * 100 ;
}
return {
icons : Object.fromEntries (
icons . map (( icon ) => [
icon ,
{
uniqueRepositories : iconTotals [ icon ],
exclusive : exclusive [ icon ],
percentageOfTotal :
totalRepositories === 0 ? 0 : ( iconTotals [ icon ] / totalRepositories ) * 100 ,
exclusivePercentageOfTotal :
totalRepositories === 0 ? 0 : ( exclusive [ icon ] / totalRepositories ) * 100 ,
},
]),
),
combinations ,
totalRepositories ,
repositoriesSearched ,
};
};
const formatNumber = ( value : number ) => value . toLocaleString ( 'en-US' );
const formatPercent = ( value : number ) => ` ${ value . toFixed ( 1 ) } %` ;
const combinationLabel = ( icons : string [], totalQueriedIcons : number ) =>
icons . length === 1
? ` ${ icons [ 0 ] } only`
: icons . length === 2 && totalQueriedIcons === 2
? 'both'
: icons . join ( ' + ' );
const printHuman = ( result : AnalysisResult ) => {
const { summary , metadata } = result ;
console . log ( 'Lucide icon usage on public GitHub repositories' );
for ( const [ icon , stats ] of Object . entries ( summary . icons )) {
console . log ( ` ${ icon } ` );
console . log ( ` unique repositories: ${ formatNumber ( stats . uniqueRepositories ) } ` );
console . log (
` exclusive: ${ formatNumber ( stats . exclusive ) } ( ${ formatPercent ( stats . exclusivePercentageOfTotal ) } )` ,
);
}
console . log ( 'Combinations among repositories using any queried icon:' );
for ( const combination of Object . values ( summary . combinations ). sort (( a , b ) => b . count - a . count )) {
const label = combinationLabel ( combination . icons , Object . keys ( summary . icons ). length );
console . log (
` ${ label } : ${ formatNumber ( combination . count ) } ( ${ formatPercent ( combination . percentage ) } )` ,
);
}
console . log ( 'Per package:' );
for ( const [ packageName , packageSummary ] of Object . entries ( summary . packages )) {
console . log ( ` ${ packageName } ` );
for ( const [ icon , stats ] of Object . entries ( packageSummary . icons )) {
console . log (
` ${ icon } : ${ formatNumber ( stats . uniqueRepositories ) } repositories, ${ formatNumber ( stats . exclusive ) } exclusive ( ${ formatPercent ( stats . exclusivePercentageOfTotal ) } )` ,
);
}
const combinations = Object . values ( packageSummary . combinations ). sort (
( a , b ) => b . count - a . count ,
);
if ( combinations . length > 0 ) {
console . log ( ' combinations:' );
for ( const combination of combinations ) {
console . log (
` ${ combinationLabel ( combination . icons , Object . keys ( summary . icons ). length ) } : ${ formatNumber ( combination . count ) } ( ${ formatPercent ( combination . percentage ) } )` ,
);
}
}
console . log ( ` total repositories: ${ formatNumber ( packageSummary . totalRepositories ) } ` );
console . log (
` repositories sampled/searched: ${ formatNumber ( packageSummary . repositoriesSearched ) } ` ,
);
}
console . log ( `total repositories: ${ formatNumber ( summary . totalRepositories ) } ` );
console . log ( `repositories sampled/searched: ${ formatNumber ( summary . repositoriesSearched ) } ` );
console . log ( `queries performed: ${ formatNumber ( metadata . queries . length ) } ` );
2026-10-09 06:41:45 +00:00
if ( result . usage ) printUsage ( result . usage );
2026-08-31 17:54:23 +02:00
const cappedQueries = metadata . queries . filter (( query ) => query . capped ). length ;
const incompleteQueries = metadata . queries . filter (( query ) => query . incompleteResults ). length ;
if ( cappedQueries > 0 || incompleteQueries > 0 || metadata . warnings . length > 0 ) {
console . log ( 'Warnings:' );
for ( const warning of metadata . warnings ) console . log ( ` ${ warning } ` );
}
};
2026-10-09 06:41:45 +00:00
const printUsage = ( usage : Record < string , IconUsage >) => {
const formatTop = ( values : Array < { value : string ; count : number } > , limit = 6 ) =>
values
. slice ( 0 , limit )
. map (({ value , count }) => ` ${ value } ( ${ count } )` )
. join ( ', ' );
console . log ( 'Usage in context:' );
for ( const [ icon , { summary , descriptions , descriptionError , sampled }] of Object . entries (
usage ,
)) {
console . log (
` ${ icon } ( ${ formatNumber ( summary . contexts ) } contexts from ${ formatNumber ( summary . repositories ) } repositories ${ sampled ? ', sampled' : '' } )` ,
);
if ( summary . contexts === 0 ) {
console . log ( ' no usage contexts found' );
continue ;
}
if ( descriptions && descriptions . length > 0 ) {
console . log ( ' use cases:' );
for ( const description of descriptions ) {
console . log (
2026-10-09 09:46:01 +00:00
` - ${ description . useCase . join ( ' / ' ) } (~ ${ formatPercent ( description . sampleShare * 100 ) } )` ,
2026-10-09 06:41:45 +00:00
);
console . log ( ` ${ description . description } ` );
for ( const example of description . examples ) console . log ( ` ${ example } ` );
}
}
if ( descriptionError ) console . log ( ` description failed: ${ descriptionError } ` );
if ( summary . topLabels . length > 0 ) console . log ( ` labels: ${ formatTop ( summary . topLabels ) } ` );
if ( summary . topAncestors . length > 0 )
console . log ( ` parents: ${ formatTop ( summary . topAncestors ) } ` );
if ( summary . topHosts . length > 0 ) console . log ( ` passed to: ${ formatTop ( summary . topHosts ) } ` );
if ( summary . topHandlers . length > 0 )
console . log ( ` handlers: ${ formatTop ( summary . topHandlers ) } ` );
console . log ( ' examples:' );
for ( const example of summary . examples . slice ( 0 , 5 )) {
console . log ( ` - ${ example . description } ` );
console . log ( ` ${ example . htmlUrl } ` );
}
}
};
2026-10-09 07:00:21 +00:00
/**
2026-10-09 09:38:14 +00:00
* Writes `{ [icon]: [...usage] }` for the icons finished so far: the grouped
* use cases when `--describe` ran, otherwise every individual usage context.
* Written to a temporary file first so an interruption never leaves it half-written.
2026-10-09 07:00:21 +00:00
*/
2026-10-09 09:38:14 +00:00
const writeOutput = async (
file : string ,
icons : string [],
usage : Record < string , IconUsage >,
repositories : Map < string , RepositoryRecord >,
options : CliOptions ,
) => {
2026-10-09 07:00:21 +00:00
const output = Object . fromEntries (
2026-10-09 09:38:14 +00:00
icons . map (( icon ) => {
if ( options . describe ) return [ icon , usage [ icon ] ? . descriptions ?? []];
const usages = [... repositories . entries ()]. flatMap (([ repository , record ]) =>
2026-10-09 07:00:21 +00:00
record . matches
. filter (( match ) => match . icon === icon )
. flatMap (( match ) =>
( match . contexts ?? []). map (( context ) => ({
description : context.description ,
repository ,
url : ` ${ match . htmlUrl } #L ${ context . line } ` ,
kind : context.kind ,
parents : context.ancestors ,
component : context.component ,
labels : [... context . labels , ... context . i18nKeys ],
handlers : context.handlers ,
links : context.links ,
})),
),
);
return [ icon , usages ];
}),
);
await fs . mkdir ( path . dirname ( file ), { recursive : true });
2026-10-09 09:38:14 +00:00
const temporaryFile = ` ${ file } .tmp` ;
await fs . writeFile ( temporaryFile , ` ${ JSON . stringify ( output , null , 2 ) } \ n` );
await fs . rename ( temporaryFile , file );
status ( `wrote usage for ${ icons . length } / ${ options . icons . length } icon(s) to ${ file } ` );
2026-10-09 07:00:21 +00:00
};
2026-08-31 17:54:23 +02:00
const printCsv = ( result : AnalysisResult ) => {
2026-10-09 06:41:45 +00:00
const rows = [[ 'repository' , 'icons' , 'packages' , 'matches' , 'contexts' ]];
2026-08-31 17:54:23 +02:00
for ( const [ repo , record ] of Object . entries ( result . repositories )) {
rows . push ([
repo ,
record . icons . join ( '|' ),
record . packages . join ( '|' ),
record . matches . map (( match ) => ` ${ match . icon } : ${ match . packageName } : ${ match . path } ` ). join ( '|' ),
2026-10-09 06:41:45 +00:00
record . matches
. flatMap (( match ) =>
( match . contexts ?? []). map (( context ) => ` ${ match . icon } : ${ context . description } ` ),
)
. join ( '|' ),
2026-08-31 17:54:23 +02:00
]);
}
console . log (
rows . map (( row ) => row . map (( cell ) => `" ${ cell . replace ( /"/g , '""' ) } "` ). join ( ',' )). join ( '\n' ),
);
};
try {
const options = parseArgs ( process . argv . slice ( 2 ));
2026-10-09 06:41:45 +00:00
options . icons = await resolveIconNames ([
... options . icons ,
...( await readIconsFiles ( options . iconsFiles )),
]);
2026-08-31 17:54:23 +02:00
const result = await analyze ( options );
if ( options . json ) {
console . log ( JSON . stringify ( result , null , 2 ));
} else if ( options . csv ) {
printCsv ( result );
} else {
printHuman ( result );
}
} catch ( error ) {
console . error (( error as Error ). message );
process . exit ( 1 );
}