mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-13 13:48:26 +00:00
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:
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user