diff --git a/scripts/iconUsage.md b/scripts/iconUsage.md index da50fdcad..7b3adee8a 100644 --- a/scripts/iconUsage.md +++ b/scripts/iconUsage.md @@ -12,6 +12,24 @@ GitHub code search requires authentication, so set `GITHUB_TOKEN` or pass `--tok 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 ` 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 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 several use cases per icon, each with a short lowercase `useCase` phrase in the style of the `use-cases` field in `icons/*.json`, 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. + +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`. diff --git a/scripts/iconUsage.mts b/scripts/iconUsage.mts index 613c185f9..37daa245c 100644 --- a/scripts/iconUsage.mts +++ b/scripts/iconUsage.mts @@ -2,6 +2,16 @@ import 'dotenv/config'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + describeIconUsage, + extractContexts, + summarizeContexts, + type ContextMatch, + type IconContextSummary, + type UsageContext, + type UsageDescription, +} from './iconUsageContext.mts'; const VERSION = '0.1.0'; const CACHE_DIR = path.resolve(process.cwd(), '.cache/lucide-icon-usage'); @@ -13,6 +23,10 @@ const DEFAULT_MAX_PAGES = 2; const DEFAULT_MAX_FILES_PER_QUERY = 200; const THOROUGH_MAX_PAGES = 10; const THOROUGH_MAX_FILES_PER_QUERY = 1000; +const DEFAULT_MAX_CONTEXTS = 40; +const MAX_CONTEXTS_PER_REPOSITORY = 2; +const DEFAULT_DESCRIBE_MODEL = 'gpt-5-mini'; +const ICONS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../icons'); const SUPPORTED_PACKAGES = [ 'lucide-react', @@ -137,6 +151,11 @@ type CliOptions = { maxFilesPerQuery: number; tokenEnv: string; cacheDir: string; + context: boolean; + describe: boolean; + maxContexts: number; + model: string; + iconsFiles: string[]; }; type SearchItem = { @@ -183,9 +202,17 @@ type RepositoryRecord = { path: string; htmlUrl: string; evidence: string; + contexts?: UsageContext[]; }>; }; +type IconUsage = { + sampled: boolean; + summary: IconContextSummary; + descriptions?: UsageDescription[]; + descriptionError?: string; +}; + type AnalysisResult = { metadata: { version: string; @@ -203,12 +230,16 @@ type AnalysisResult = { maxPages: number; maxFilesPerQuery: number; cacheDir: string; + context: boolean; + maxContexts: number | null; + describeModel: string | null; queries: QueryRecord[]; warnings: string[]; biases: string[]; }; repositories: Record; summary: ReturnType; + usage?: Record; }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -257,6 +288,11 @@ const parseArgs = (argv: string[]): CliOptions => { maxFilesPerQuery: DEFAULT_MAX_FILES_PER_QUERY, tokenEnv: 'GITHUB_TOKEN', cacheDir: CACHE_DIR, + context: false, + describe: false, + maxContexts: DEFAULT_MAX_CONTEXTS, + model: process.env.OPENAI_MODEL ?? DEFAULT_DESCRIBE_MODEL, + iconsFiles: [], }; for (let index = 0; index < argv.length; index += 1) { @@ -342,6 +378,44 @@ const parseArgs = (argv: string[]): CliOptions => { ); continue; } + 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; + } + 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; + } if (arg === '--verbose' || arg === '-v') { options.verbose = true; continue; @@ -370,14 +444,63 @@ const parseArgs = (argv: string[]): CliOptions => { options.icons = [...new Set(options.icons.map((icon) => icon.trim()).filter(Boolean))]; options.packages = [...new Set(options.packages)]; - if (options.icons.length < 2) { + if (options.icons.length === 0 && options.iconsFiles.length === 0) { throw new Error( - 'Pass at least two Lucide icon names, for example: pnpm icon-usage trash trash-2', + 'Pass at least one Lucide icon name, for example: pnpm icon-usage trash trash-2', ); } + if (options.describe && !process.env.OPENAI_API_KEY) { + throw new Error('--describe requires OPENAI_API_KEY to be set.'); + } return options; }; +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), + ); + const parsePositiveInteger = (value: string | undefined, option: string) => { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { @@ -406,6 +529,15 @@ Options: --max-files-per-query 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} + --icons-file 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 Stop searching an icon once n contexts are collected when + --context/--describe is set (default: ${DEFAULT_MAX_CONTEXTS}) + --model Model for --describe (default: $OPENAI_MODEL or ${DEFAULT_DESCRIBE_MODEL}) --verbose, -v Print detailed progress logs on stderr --token-env Token environment variable (default: GITHUB_TOKEN) --cache-dir Cache directory (default: .cache/lucide-icon-usage) @@ -753,6 +885,12 @@ const analyze = async (options: CliOptions): Promise => { ); let completedQuerySlices = 0; let contentFilesChecked = 0; + const contextMatches = new Map( + options.icons.map((icon) => [icon, [] as ContextMatch[]]), + ); + const contextLimitReached = (icon: string) => + options.context && (contextMatches.get(icon)?.length ?? 0) >= options.maxContexts; + const sampledIcons = new Set(); status( `starting: icons=${options.icons.join(', ')} packages=${options.packages.join(', ')} query-slices=${querySlices} max-pages=${options.maxPages} max-files-per-query=${options.maxFilesPerQuery}`, @@ -768,6 +906,15 @@ const analyze = async (options: CliOptions): Promise => { for (const icon of options.icons) { for (const packageName of options.packages) { for (const { queryKind, fileScope, query } of queriesFor(icon, packageName, options)) { + if (contextLimitReached(icon)) { + sampledIcons.add(icon); + completedQuerySlices += 1; + progress( + options, + `skipping ${icon} ${packageName} ${fileScope.label} ${queryKind}: --max-contexts reached`, + ); + continue; + } const { items, record } = await fetchAllSearchResults( client, icon, @@ -785,6 +932,10 @@ const analyze = async (options: CliOptions): Promise => { progress(options, `query slice progress: ${completedQuerySlices}/${querySlices}`); for (const [itemIndex, item] of items.entries()) { + if (contextLimitReached(icon)) { + sampledIcons.add(icon); + break; + } const repo = item.repository.full_name; candidateRepositories.add(repo); candidatePackageRepositories.get(packageName)?.add(repo); @@ -816,13 +967,39 @@ const analyze = async (options: CliOptions): Promise => { if (!recordForRepo.icons.includes(icon)) recordForRepo.icons.push(icon); if (!recordForRepo.packages.includes(packageName)) recordForRepo.packages.push(packageName); + const contexts = options.context + ? extractContexts(source, packageName, icon, usageNamesForPackage(icon, packageName)) + : undefined; recordForRepo.matches.push({ icon, packageName, path: item.path, htmlUrl: item.html_url, evidence, + ...(contexts ? { contexts } : {}), }); + 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})`, + ); + } repositories.set(repo, recordForRepo); if (!options.verbose) { status(`match: ${repo} uses ${icon} via ${packageName} in ${item.path}`); @@ -858,6 +1035,14 @@ const analyze = async (options: CliOptions): Promise => { ); const warnings = buildWarnings(queries); + 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.`, + ); + } + const usage = options.context + ? await buildUsage(options, contextMatches, sampledIcons) + : undefined; return { metadata: { version: VERSION, @@ -884,6 +1069,9 @@ const analyze = async (options: CliOptions): Promise => { maxPages: options.maxPages, maxFilesPerQuery: options.maxFilesPerQuery, cacheDir: options.cacheDir, + context: options.context, + maxContexts: options.context ? options.maxContexts : null, + describeModel: options.describe ? options.model : null, queries, warnings, biases: [ @@ -904,9 +1092,57 @@ const analyze = async (options: CliOptions): Promise => { candidateRepositories.size, candidatePackageRepositories, ), + ...(usage ? { usage } : {}), }; }; +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 []; + } +}; + +const buildUsage = async ( + options: CliOptions, + contextMatches: Map, + sampledIcons: Set, +) => { + const usage: Record = {}; + for (const icon of options.icons) { + const matches = contextMatches.get(icon) ?? []; + const summary = summarizeContexts(matches); + usage[icon] = { sampled: sampledIcons.has(icon), summary }; + if (!options.describe || matches.length === 0) continue; + + status(`describing ${icon} from ${matches.length} contexts with ${options.model}`); + try { + usage[icon].descriptions = await describeIconUsage({ + icon, + matches, + summary, + existingUseCases: await readExistingUseCases(icon), + model: options.model, + maxPromptContexts: options.maxContexts, + readCache: (key) => + options.refresh + ? Promise.resolve(null) + : readCachedJson( + path.join(options.cacheDir, 'describe', `${key}.json`), + ), + writeCache: (key, value) => + writeCachedJson(path.join(options.cacheDir, 'describe', `${key}.json`), value), + }); + } catch (error) { + usage[icon].descriptionError = (error as Error).message; + status(`describing ${icon} failed: ${(error as Error).message}`); + } + } + return usage; +}; + const gitCommit = async () => { try { const { execFile } = await import('node:child_process'); @@ -1122,6 +1358,8 @@ const printHuman = (result: AnalysisResult) => { console.log(`repositories sampled/searched: ${formatNumber(summary.repositoriesSearched)}`); console.log(`queries performed: ${formatNumber(metadata.queries.length)}`); + if (result.usage) printUsage(result.usage); + 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) { @@ -1130,14 +1368,62 @@ const printHuman = (result: AnalysisResult) => { } }; +const printUsage = (usage: Record) => { + 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( + ` - ${description.useCase} (~${formatPercent(description.sampleShare * 100)})`, + ); + 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}`); + } + } +}; + const printCsv = (result: AnalysisResult) => { - const rows = [['repository', 'icons', 'packages', 'matches']]; + const rows = [['repository', 'icons', 'packages', 'matches', 'contexts']]; 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('|'), + record.matches + .flatMap((match) => + (match.contexts ?? []).map((context) => `${match.icon}: ${context.description}`), + ) + .join('|'), ]); } console.log( @@ -1147,6 +1433,10 @@ const printCsv = (result: AnalysisResult) => { try { const options = parseArgs(process.argv.slice(2)); + options.icons = await resolveIconNames([ + ...options.icons, + ...(await readIconsFiles(options.iconsFiles)), + ]); const result = await analyze(options); if (options.json) { console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/iconUsageContext.mts b/scripts/iconUsageContext.mts new file mode 100644 index 000000000..e4182b473 --- /dev/null +++ b/scripts/iconUsageContext.mts @@ -0,0 +1,620 @@ +// 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; + 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 = (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(); + 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` 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(` { + 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) => { + 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(`(?(); + 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(); + 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 = {}; + 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(); + 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; + writeCache: (key: string, value: UsageDescription[]) => Promise; +}; + +/** + * 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 => { + 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 into distinct use cases and describe each one. + +Guidelines: +- useCase: short lowercase phrase describing the concrete situation the icon represents (e.g. "deleting an item from a list"). No trailing punctuation. Match the style of the existing use-cases if any. +- 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 use case. +- examples: ids of up to 3 samples that best illustrate the use case. +- Return between 1 and 8 use cases, most common first. Ignore samples without enough context to judge. +- Base everything on the samples; do not invent use cases that are not supported by them. + +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.string(), + description: z.string(), + sampleShare: z.number(), + examples: z.array(z.number()), + }), + ), + }); + + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + 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; +};