feat(scripts): describe icon usage in context

Add --context and --describe to the icon usage script. --context extracts
how each matched icon is used (parent elements, labels, handlers, links,
snippet) and summarizes it per icon. --describe groups those usages into
use-case descriptions with an OpenAI model. Also adds --max-contexts and
--icons-file, accepts export names like Trash2, and allows a single icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q2D9GYbnSV56MAVVhHPRhH
This commit is contained in:
Claude
2026-10-09 06:41:45 +00:00
parent ec3c3906f7
commit 924b5fda27
3 changed files with 931 additions and 3 deletions
+18
View File
@@ -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 <path>` to read names from a file with one name per line, such as a list of `lucide-react` exports.
## Usage In Context
```sh
pnpm icon-usage trash-2 --context
pnpm icon-usage trash-2 --describe
pnpm icon-usage --icons-file exports.txt --describe --max-contexts 30
```
`--context` extracts how each icon is used in every validated file: what kind of usage it is (a JSX/template element, a prop such as `icon={Trash2}`, a config object such as `{ title: 'Settings', icon: Settings }`, or a `data-lucide` attribute), the parent elements (for example `DropdownMenuItem` or `Button`), the enclosing component, label text (`aria-label`, `title`, visible text, i18n keys like `t('actions.delete')`), event handlers, links, and a short code snippet. Each usage gets a heuristic description such as `Trash2 in <DropdownMenuItem> labelled "Delete Project" within NavProjects`. Each icon gets a summary of the most common labels, parents and handlers, plus example links to the exact lines on GitHub.
`--describe` does the same, then sends the collected contexts to an OpenAI model (`OPENAI_API_KEY` required; `--model` or `OPENAI_MODEL` selects the model, `gpt-5-mini` by default). The model groups them into 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`.
+293 -3
View File
@@ -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<string, RepositoryRecord>;
summary: ReturnType<typeof summarize>;
usage?: Record<string, IconUsage>;
};
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 <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}
--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})
--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)
@@ -753,6 +885,12 @@ const analyze = async (options: CliOptions): Promise<AnalysisResult> => {
);
let completedQuerySlices = 0;
let contentFilesChecked = 0;
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>();
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<AnalysisResult> => {
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<AnalysisResult> => {
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<AnalysisResult> => {
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<AnalysisResult> => {
);
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<AnalysisResult> => {
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<AnalysisResult> => {
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<string, ContextMatch[]>,
sampledIcons: Set<string>,
) => {
const usage: Record<string, IconUsage> = {};
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<UsageDescription[]>(
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<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(
` - ${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));
+620
View File
@@ -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<string, number>;
examples: Array<{ repository: string; htmlUrl: string; description: string }>;
};
const MAX_CONTEXTS_PER_FILE = 3;
const SNIPPET_LINES_BEFORE = 3;
const SNIPPET_LINES_AFTER = 3;
const LOOKBEHIND_CHARS = 2500;
const MAX_SNIPPET_LINE_LENGTH = 160;
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const toKebabCase = (value: string) =>
value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
const unique = <T,>(values: T[]) => [...new Set(values)];
const clean = (value: string) => value.replace(/\s+/g, ' ').trim();
/**
* Resolves the identifiers a file uses for an icon, following `as` aliases
* and default imports of direct icon paths (e.g. `lucide-react/icons/trash`).
*/
export const localNamesFor = (
source: string,
packageName: string,
icon: string,
usageNames: string[],
) => {
const names = new Set<string>();
const escapedPackage = escapeRegExp(packageName);
const namedImportRegex = new RegExp(
`import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+['"]${escapedPackage}['"]`,
'g',
);
for (const match of source.matchAll(namedImportRegex)) {
for (const specifier of (match[1] ?? '').split(',')) {
const parts = specifier
.trim()
.replace(/^type\s+/, '')
.split(/\s+as\s+/);
if (!usageNames.includes(parts[0]?.trim() ?? '')) continue;
names.add((parts[1] ?? parts[0]).trim());
}
}
const directImportRegex = new RegExp(
`import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"]${escapeRegExp(`${packageName}/icons/${icon}`)}(?:\\.js)?['"]`,
'g',
);
for (const match of source.matchAll(directImportRegex)) names.add(match[1]);
// Packages without imports in the template (vanilla `lucide`, Angular) are
// referenced by their export names directly.
if (names.size === 0) usageNames.forEach((name) => names.add(name));
return [...names];
};
/** Character ranges that are import/export statements and must not count as usage. */
const statementRanges = (source: string) => {
const ranges: Array<[number, number]> = [];
const regex =
/(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[\w$*\s,]*\{[\s\S]*?\}|[\w$*\s,]+?)\s*from\s*['"][^'"]+['"]/g;
for (const match of source.matchAll(regex)) {
ranges.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
}
return ranges;
};
type Tag = { name: string; start: number; end: number; attributes: string };
/**
* Finds the end of a JSX/HTML tag that starts at `start`, skipping over `{}`
* expressions, strings and arrow functions so `onClick={() => a > b}` does not
* end the tag early.
*/
const findTagEnd = (source: string, start: number, limit: number) => {
let depth = 0;
let quote: string | null = null;
for (let index = start + 1; index < limit; index += 1) {
const char = source[index];
if (quote) {
if (char === quote && source[index - 1] !== '\\') quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') quote = char;
else if (char === '{') depth += 1;
else if (char === '}') depth = Math.max(0, depth - 1);
else if (char === '>' && depth === 0 && source[index - 1] !== '=') return index;
else if (
char === '<' &&
depth === 0 &&
index > start + 1 &&
/[A-Za-z/]/.test(source[index + 1] ?? '')
)
return -1;
}
return -1;
};
const VOID_ELEMENTS = new Set([
'img',
'input',
'br',
'hr',
'meta',
'link',
'source',
'area',
'col',
'embed',
'wbr',
]);
/**
* Scans markup before `position` and returns the still-open elements
* (innermost last) plus the tag `position` sits inside, if any.
*/
const openElementsAt = (source: string, position: number) => {
const windowStart = Math.max(0, position - LOOKBEHIND_CHARS);
const stack: Tag[] = [];
let host: Tag | null = null;
const tagRegex = /<(\/?)([A-Za-z][\w.:-]*)/g;
tagRegex.lastIndex = windowStart;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(source)) && match.index < position) {
const [, closing, name] = match;
// `a < b` or generics like `Array<string>` are not markup.
const before = source[match.index - 1] ?? '';
if (!closing && /[\w$)\]]/.test(before)) continue;
const end = findTagEnd(source, match.index, Math.min(source.length, position + 2000));
if (end === -1) continue;
const tag = { name, start: match.index, end, attributes: source.slice(match.index, end + 1) };
if (end >= position) {
if (!closing) host = tag;
break;
}
if (closing) {
const openIndex = stack.map((item) => item.name).lastIndexOf(name);
if (openIndex !== -1) stack.splice(openIndex);
} else if (source[end - 1] !== '/' && !VOID_ELEMENTS.has(name.toLowerCase())) {
stack.push(tag);
}
tagRegex.lastIndex = end + 1;
}
return { stack, host };
};
const LABEL_ATTRIBUTES = [
'aria-label',
'title',
'label',
'tooltip',
'alt',
'placeholder',
'content',
'text',
'name',
];
const attributeValues = (markup: string, names: string[]) => {
const values: string[] = [];
const pattern = names.map(escapeRegExp).join('|');
const regex = new RegExp(
`(?:^|[\\s{,])(?:${pattern})\\s*[=:]\\s*(?:\\{\\s*)?(?:"([^"]{1,80})"|'([^']{1,80})'|\`([^\`$]{1,80})\`)`,
'g',
);
for (const match of markup.matchAll(regex)) {
const value = clean(match[1] ?? match[2] ?? match[3] ?? '');
if (value) values.push(value);
}
return values;
};
const handlerValues = (markup: string) => {
const values: string[] = [];
const regex =
/(?:^|[\s(])(on[A-Z]\w*|@[\w.-]+|v-on:[\w.-]+|\(click\)|on:\w+|onclick)\s*=\s*\{?\s*["']?(?:\(\)\s*=>\s*)?([\w$.]+)/g;
for (const match of markup.matchAll(regex)) {
if (match[2] && !['function', 'async', 'e', 'event'].includes(match[2])) {
values.push(`${match[1]}=${match[2]}`);
}
}
return values;
};
const linkValues = (markup: string) =>
attributeValues(markup, ['href', 'to', 'url', 'path', 'route', 'routerLink']);
const i18nKeys = (markup: string) => {
const keys: string[] = [];
const regex =
/\b(?:t|i18n\.t|\$t|translate|formatMessage|intl\.formatMessage)\(\s*(?:\{\s*id:\s*)?['"`]([\w.:-]{2,80})['"`]/g;
for (const match of markup.matchAll(regex)) keys.push(match[1]);
return keys;
};
/** Content range of an element (between its open and close tags), bounded in size. */
const elementContent = (source: string, parent: Tag | undefined, fallbackStart: number) => {
const start = parent ? parent.end + 1 : fallbackStart;
const closing = parent ? source.indexOf(`</${parent.name}`, start) : -1;
const end =
closing === -1 ? Math.min(source.length, start + 300) : Math.min(closing, start + 600);
return source.slice(start, end);
};
/** Visible text inside an element, with nested tags and expressions removed. */
const elementText = (source: string, parent: Tag | undefined, fallbackStart: number) => {
const content = elementContent(source, parent, fallbackStart);
const text = content
.replace(/<[^>]*>/g, ' ')
.replace(/\{[^{}]*\}/g, ' ')
.replace(/[{}]/g, ' ');
const value = clean(text);
if (!value || value.length > 80 || /[;=]|=>|\breturn\b/.test(value)) return [];
return [value];
};
const enclosingComponent = (source: string, position: number) => {
const before = source.slice(Math.max(0, position - 20000), position);
const regex =
/(?:function\s+([A-Z][\w$]*)|(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:\([^)]*\)|[\w$]+)?\s*(?:=>|function)|class\s+([A-Z][\w$]*))/g;
let name: string | null = null;
for (const match of before.matchAll(regex)) name = match[1] ?? match[2] ?? match[3] ?? name;
return name;
};
const snippetAround = (lines: string[], lineIndex: number) =>
lines
.slice(Math.max(0, lineIndex - SNIPPET_LINES_BEFORE), lineIndex + SNIPPET_LINES_AFTER + 1)
.map((line) =>
line.length > MAX_SNIPPET_LINE_LENGTH ? `${line.slice(0, MAX_SNIPPET_LINE_LENGTH)}…` : line,
)
.join('\n');
/** Object literal around a reference such as `{ title: 'Settings', icon: Settings }`. */
const objectLiteralAround = (source: string, position: number) => {
let depth = 0;
let start = -1;
for (let index = position; index >= Math.max(0, position - 600); index -= 1) {
const char = source[index];
if (char === '}') depth += 1;
else if (char === '{') {
if (depth === 0) {
start = index;
break;
}
depth -= 1;
}
}
if (start === -1) return null;
depth = 0;
for (let index = start; index < Math.min(source.length, start + 1200); index += 1) {
const char = source[index];
if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
return null;
};
const describe = (context: Omit<UsageContext, 'description' | 'snippet'>) => {
const parent = context.ancestors.at(-1);
const where =
context.kind === 'object'
? 'config object'
: context.kind === 'prop'
? `\`${context.hostTag}\` prop`
: parent
? `<${parent}>`
: context.kind === 'data-attribute'
? `<${context.hostTag ?? 'i'} data-lucide>`
: 'standalone element';
const parts = [`${context.localName} in ${where}`];
const label = context.labels[0] ?? context.i18nKeys[0];
if (label) parts.push(`labelled "${label}"`);
if (context.handlers.length > 0)
parts.push(`handling ${context.handlers.slice(0, 2).join(', ')}`);
if (context.links.length > 0) parts.push(`linking to ${context.links[0]}`);
if (context.iconProps && /spin|animate|loading/i.test(context.iconProps))
parts.push('(animated)');
if (context.component) parts.push(`within ${context.component}`);
return parts.join(' ');
};
/** Extracts up to a few usage contexts of an icon from a single source file. */
export const extractContexts = (
source: string,
packageName: string,
icon: string,
usageNames: string[],
): UsageContext[] => {
const localNames = localNamesFor(source, packageName, icon, usageNames);
const ranges = statementRanges(source);
const lines = source.split('\n');
const lineStarts: number[] = [];
lines.reduce((offset, line) => {
lineStarts.push(offset);
return offset + line.length + 1;
}, 0);
const lineAt = (position: number) => {
let low = 0;
let high = lineStarts.length - 1;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (lineStarts[mid] <= position) low = mid;
else high = mid - 1;
}
return low;
};
const namePattern = unique([
...localNames.map(escapeRegExp),
// Vue templates may use kebab-case component names.
...(packageName === '@lucide/vue'
? localNames.map((name) => escapeRegExp(toKebabCase(name)))
: []),
]).join('|');
const occurrences: Array<{ position: number; name: string; dataAttribute: boolean }> = [];
for (const match of source.matchAll(
new RegExp(`(?<![\\w$.'"/-])(${namePattern})(?![\\w$-])`, 'g'),
)) {
occurrences.push({ position: match.index ?? 0, name: match[1], dataAttribute: false });
}
if (packageName === 'lucide') {
for (const match of source.matchAll(
new RegExp(`data-lucide\\s*=\\s*["']${escapeRegExp(icon)}["']`, 'g'),
)) {
occurrences.push({ position: match.index ?? 0, name: icon, dataAttribute: true });
}
}
const contexts: UsageContext[] = [];
const seenLines = new Set<number>();
for (const { position, name, dataAttribute } of occurrences.sort(
(a, b) => a.position - b.position,
)) {
if (contexts.length >= MAX_CONTEXTS_PER_FILE) break;
if (ranges.some(([start, end]) => position >= start && position < end)) continue;
const lineIndex = lineAt(position);
if (seenLines.has(lineIndex)) continue;
const lineText = lines[lineIndex];
if (/^\s*(\/\/|\*|\/\*)/.test(lineText)) continue;
// Registration calls such as `createIcons({ icons: { Trash } })` carry no context.
if (
/(?:createIcons\s*\(|LucideAngularModule\.pick\s*\(|addIcons\s*\(|icons\s*:\s*\{)[^}<]*$/.test(
source.slice(Math.max(0, position - 300), position),
)
)
continue;
const isElement = source[position - 1] === '<';
const { stack, host } = isElement
? openElementsAt(source, position - 1)
: openElementsAt(source, position);
const ownTag = isElement
? (() => {
const end = findTagEnd(source, position - 1, Math.min(source.length, position + 2000));
return end === -1 ? null : source.slice(position - 1, end + 1);
})()
: null;
let kind: UsageContext['kind'] = 'reference';
let hostTag: string | null = null;
let markupForSignals = '';
if (dataAttribute) {
kind = 'data-attribute';
hostTag = host?.name ?? null;
} else if (isElement) {
kind = 'element';
} else if (host) {
kind = 'prop';
hostTag = host.name;
markupForSignals = host.attributes;
} else {
const object = objectLiteralAround(source, position);
if (object && new RegExp(`[\\w$]+\\s*:\\s*${escapeRegExp(name)}\\b`).test(object)) {
kind = 'object';
markupForSignals = object;
}
}
const ancestors = stack.slice(-3).map((tag) => tag.name);
const parent = stack.at(-1);
const ancestorMarkup = stack
.slice(-2)
.map((tag) => tag.attributes)
.join(' ');
const signalMarkup = [markupForSignals, ancestorMarkup].join(' ');
const labels = unique([
...(kind === 'object' ? attributeValues(markupForSignals, LABEL_ATTRIBUTES) : []),
...attributeValues(
signalMarkup,
LABEL_ATTRIBUTES.filter((attr) => attr !== 'name'),
),
...(kind === 'element' || kind === 'data-attribute' || kind === 'prop'
? elementText(source, kind === 'prop' && host ? host : parent, position)
: []),
]).slice(0, 4);
const base = {
line: lineIndex + 1,
kind,
localName: name,
hostTag,
ancestors,
component: enclosingComponent(source, position),
labels,
i18nKeys: unique(
i18nKeys(
kind === 'object'
? markupForSignals
: `${signalMarkup} ${elementContent(source, kind === 'prop' && host ? host : parent, position)}`,
),
).slice(0, 3),
handlers: unique(handlerValues(signalMarkup)).slice(0, 3),
links: unique(linkValues(signalMarkup)).slice(0, 2),
iconProps: ownTag
? clean(ownTag.slice(name.length + 1, -1).replace(/\/$/, '')).slice(0, 120) || null
: null,
};
seenLines.add(lineIndex);
contexts.push({
...base,
snippet: snippetAround(lines, lineIndex),
description: describe(base),
});
}
return contexts;
};
const topValues = (values: string[], limit = 8) => {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([value, count]) => ({ value, count }));
};
/** Aggregates the contexts collected for one icon into frequency tables. */
export const summarizeContexts = (matches: ContextMatch[]): IconContextSummary => {
const contexts = matches.map((match) => match.context);
const kinds: Record<string, number> = {};
for (const context of contexts) kinds[context.kind] = (kinds[context.kind] ?? 0) + 1;
// Prefer one example per repository so a single project cannot dominate.
const examples: IconContextSummary['examples'] = [];
const exampleRepos = new Set<string>();
for (const match of matches) {
if (exampleRepos.has(match.repository)) continue;
exampleRepos.add(match.repository);
examples.push({
repository: match.repository,
htmlUrl: `${match.htmlUrl}#L${match.context.line}`,
description: match.context.description,
});
if (examples.length >= 10) break;
}
return {
contexts: contexts.length,
repositories: new Set(matches.map((match) => match.repository)).size,
topLabels: topValues(
contexts.flatMap((context) =>
unique([...context.labels, ...context.i18nKeys].map((value) => value.toLowerCase())),
),
),
topHosts: topValues(contexts.flatMap((context) => (context.hostTag ? [context.hostTag] : []))),
topAncestors: topValues(contexts.flatMap((context) => context.ancestors.slice(-1))),
topHandlers: topValues(
contexts.flatMap((context) => context.handlers.map((handler) => handler.split('=')[0])),
),
kinds,
examples,
};
};
export type UsageDescription = {
useCase: string;
description: string;
sampleShare: number;
examples: string[];
};
type DescribeOptions = {
icon: string;
matches: ContextMatch[];
summary: IconContextSummary;
existingUseCases: string[];
model: string;
maxPromptContexts: number;
readCache: (key: string) => Promise<UsageDescription[] | null>;
writeCache: (key: string, value: UsageDescription[]) => Promise<void>;
};
/**
* Asks an OpenAI model to cluster the collected usage contexts into distinct,
* real-world use cases for the icon. Responses are cached by prompt hash.
*/
export const describeIconUsage = async ({
icon,
matches,
summary,
existingUseCases,
model,
maxPromptContexts,
readCache,
writeCache,
}: DescribeOptions): Promise<UsageDescription[]> => {
if (matches.length === 0) return [];
const samples = matches.slice(0, maxPromptContexts).map((match, index) => ({
id: index + 1,
repository: match.repository,
path: match.path,
component: match.context.component,
parents: match.context.ancestors,
labels: [...match.context.labels, ...match.context.i18nKeys],
handlers: match.context.handlers,
links: match.context.links,
snippet: match.context.snippet,
}));
const input = `You are maintaining the metadata for the Lucide icon library. Below are real usages of the \`${icon}\` icon found in public GitHub repositories, extracted from source code.
Group these usages 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;
};