// 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; }; const METADATA_INSTRUCTIONS = new URL( '../.github/instructions/metadata.instructions.md', import.meta.url, ); const FALLBACK_USE_CASE_GUIDELINES = `Write each use-case as a short phrase of roughly 4-12 words starting with a present participle verb (e.g. "Indicating a low battery charge level"), from the interface's perspective, naming the concrete context where useful. No trailing period, no first person, no marketing language, and never just the icon's name.`; /** * Returns the "Use Cases" section of the repository's metadata instructions * (including its examples), so generated use-cases follow the same style. */ const loadUseCaseGuidelines = async () => { try { const fs = await import('node:fs/promises'); const instructions = await fs.readFile(METADATA_INSTRUCTIONS, 'utf8'); const start = instructions.indexOf('## Use Cases'); if (start === -1) return FALLBACK_USE_CASE_GUIDELINES; const sections = instructions.slice(start).split(/\n(?=## )/); return sections .filter((section, index) => index === 0 || section.startsWith('## Examples')) .join('\n') .trim(); } catch { return FALLBACK_USE_CASE_GUIDELINES; } }; /** * Asks an OpenAI model to cluster the collected usage contexts into distinct, * real-world use cases for the icon. Responses are cached by prompt hash. */ export const describeIconUsage = async ({ icon, matches, summary, existingUseCases, model, maxPromptContexts, readCache, writeCache, }: DescribeOptions): Promise => { if (matches.length === 0) return []; const samples = matches.slice(0, maxPromptContexts).map((match, index) => ({ id: index + 1, repository: match.repository, path: match.path, component: match.context.component, parents: match.context.ancestors, labels: [...match.context.labels, ...match.context.i18nKeys], handlers: match.context.handlers, links: match.context.links, snippet: match.context.snippet, })); const input = `You are maintaining the metadata for the Lucide icon library. Below are real usages of the \`${icon}\` icon found in public GitHub repositories, extracted from source code. Group these usages by what the icon means in them, and describe each group. Fields for each group: - useCase: an array of 1-3 use-case phrases for this group, written exactly as the use-case guidelines below describe. Add more than one phrase only when the group covers genuinely distinct uses; never rephrase the same idea. Don't repeat a phrase across groups. - description: one or two sentences describing how and where the icon appears in the UI (e.g. inside a ghost button in a table row, next to a "Delete" label). - sampleShare: the approximate fraction (0-1) of the samples that belong to this group. - examples: ids of up to 3 samples that best illustrate the group. Rules: - Return between 1 and 4 groups, most common first. Ignore samples without enough context to judge. - The samples are your source material: extract the function the icon serves in the interface and discard implementation details such as component names, class names or file paths. - Base everything on the samples; never invent use cases that are not supported by them. - Match the style of the existing use-cases, and avoid duplicating them. Use-case guidelines (from .github/instructions/metadata.instructions.md): ${await loadUseCaseGuidelines()} Existing use-cases for "${icon}": ${JSON.stringify(existingUseCases)} Aggregated signals: ${JSON.stringify({ topLabels: summary.topLabels, topParents: summary.topAncestors, topHandlers: summary.topHandlers, kinds: summary.kinds })} Samples: ${JSON.stringify(samples, null, 1)}`; const crypto = await import('node:crypto'); const cacheKey = crypto.createHash('sha256').update(`${model}\n${input}`).digest('hex'); const cached = await readCache(cacheKey); if (cached) return cached; const [{ default: OpenAI }, { zodTextFormat }, { default: z }] = await Promise.all([ import('openai'), import('openai/helpers/zod'), import('zod'), ]); const schema = z.object({ useCases: z.array( z.object({ useCase: z.array(z.string()), description: z.string(), sampleShare: z.number(), examples: z.array(z.number()), }), ), }); // The SDK retries timeouts (408), rate limits and server errors with backoff. const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, maxRetries: 5 }); const response = await client.responses.create({ model, input, text: { format: zodTextFormat(schema, 'usage') }, }); const parsed = schema.parse(JSON.parse(response.output_text)); const descriptions = parsed.useCases.map((useCase) => ({ useCase: useCase.useCase, description: useCase.description, sampleShare: useCase.sampleShare, examples: useCase.examples .map((id) => matches[id - 1]) .filter(Boolean) .map((match) => `${match.htmlUrl}#L${match.context.line}`), })); await writeCache(cacheKey, descriptions); return descriptions; };