scripts/check-icon-usage.mjs
Rama main · réplica de lectura del repositorio original.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* Fails if UI source renders a Lucide icon directly instead of through
* `DsIcon`, or overrides a DsIcon's size or stroke locally. DsIcon owns the
* two base icon styles (16px standard, 12px compact) and the shared stroke.
*
* Usage:
* node scripts/check-icon-usage.mjs
* node scripts/check-icon-usage.mjs src/a.tsx # lint-staged (one or more files)
*
* The full run (no file arguments) also type-checks every JSX tag, so icons
* reached through a variable, alias or object property (`const Icon = …`,
* `<item.Icon />`) are caught too. The per-file run matches names imported
* from 'lucide-react' only, to stay fast on commit.
*
* Exemption: put `ds-icon:allow <reason>` in a comment on the element's line or
* the line above it. Reserve it for glyphs that are not icons (for example a
* check mark drawn inside a custom badge).
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const EXT = new Set(['.tsx', '.jsx']);
const SKIP_PATHS = new Set([
'src/components/ui/ds-icon.tsx',
'src/components/ui/ds-morph-icon.tsx',
]);
const SKIP_PREFIXES = ['src/components/ui/animate-ui/icons/'];
const SKIP_FILE_RE = /\.(test|spec)\.(tsx|jsx)$|\.stories\.(tsx|jsx)$/;
const LUCIDE_IMPORT_RE = /import\s*\{([^}]*)\}\s*from\s*['"]lucide-react['"]/gs;
const DS_TAGS = ['DsIcon', 'TaskLogIcon'];
const SIZE_CLASS_RE =
/(?<![\w:[-])(?:size|h|w)-(?:\d+(?:\.\d+)?|\[[^\]\s]+\])(?![\w.\]-])/;
const SIZE_PROP_RE = /\b(?:size|strokeWidth|absoluteStrokeWidth|width|height)=/;
function lucideNames(text) {
const names = new Set();
for (const m of text.matchAll(LUCIDE_IMPORT_RE)) {
for (const raw of m[1].split(',')) {
const part = raw.trim();
if (!part || part.startsWith('type ')) continue;
names.add(
part
.split(/\s+as\s+/)
.pop()
.trim()
);
}
}
return names;
}
/** Index of the `>` that closes the opening tag starting at `from`. */
function openTagEnd(text, from) {
let depth = 0;
for (let i = from; i < text.length; i++) {
const c = text[i];
if (c === '{') depth++;
else if (c === '}') depth--;
else if (c === '>' && depth === 0) return i;
}
return -1;
}
function lineOf(text, index) {
return text.slice(0, index).split('\n').length;
}
function exempt(lines, line) {
return [line - 1, line - 2].some(
(i) => i >= 0 && lines[i]?.includes('ds-icon:allow')
);
}
function* walk(dir) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === 'dist') continue;
yield* walk(p);
} else if (EXT.has(e.name.slice(e.name.lastIndexOf('.')))) {
yield p;
}
}
}
function checkFile(abs) {
const rel = relative(REPO_ROOT, abs).replaceAll('\\', '/');
if (
SKIP_PATHS.has(rel) ||
SKIP_PREFIXES.some((p) => rel.startsWith(p)) ||
SKIP_FILE_RE.test(rel)
) {
return [];
}
const text = readFileSync(abs, 'utf8');
const lines = text.split(/\r?\n/);
const out = [];
const names = lucideNames(text);
if (names.size > 0) {
const tag = new RegExp(`<(${[...names].join('|')})(?=[\\s/>])`, 'g');
for (const m of text.matchAll(tag)) {
const end = openTagEnd(text, m.index + m[0].length);
// TypeScript generics such as useState<File | null> are not elements.
if (end < 0 || /[|&,]/.test(text.slice(m.index, end).split('\n')[0]))
continue;
const line = lineOf(text, m.index);
if (exempt(lines, line)) continue;
out.push({
file: rel,
line,
message: `Lucide icon <${m[1]}> rendered directly. Use <DsIcon icon={${m[1]}} />.`,
});
}
}
for (const dsTag of DS_TAGS) {
for (const m of text.matchAll(new RegExp(`<${dsTag}(?=[\\s/>])`, 'g'))) {
const end = openTagEnd(text, m.index + m[0].length);
if (end < 0) continue;
const attrs = text.slice(m.index, end);
const line = lineOf(text, m.index);
if (exempt(lines, line)) continue;
const classAttr = attrs.match(/className=("[^"]*"|\{[\s\S]*?\}\s*$)/m);
if (SIZE_PROP_RE.test(attrs.replace(/illustrationSize=/g, ''))) {
out.push({
file: rel,
line,
message: `<${dsTag}> sets size or stroke locally. Choose a recipe instead.`,
});
} else if (classAttr && SIZE_CLASS_RE.test(classAttr[1])) {
out.push({
file: rel,
line,
message: `<${dsTag}> overrides its size with a utility class. Choose a recipe instead.`,
});
}
}
}
out.push(...mixedSizeControls(text, lines, rel));
return out;
}
const CONTROL_TAG_RE =
/^(button|a|label|Button|[A-Z]\w*(Trigger|Item|Button|Toggle|Tab|Chip|Pill|Link|Option))$/;
const JSX_TAG_RE =
/<(\/?)([A-Za-z][\w.]*)((?:[^<>{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?)(\/?)>/gs;
const DS_ICON_ELEMENT_RE =
/<DsIcon\b((?:[^<>{}]|\{(?:[^{}]|\{[^{}]*\})*\})*?)\/>/gs;
/**
* One control, one icon size: a button or menu item must not mix the compact
* (12px) and standard (16px) styles.
*/
function mixedSizeControls(text, lines, rel) {
const stack = [];
const controls = [];
for (const m of text.matchAll(JSX_TAG_RE)) {
const [, closing, name, , selfClosing] = m;
if (closing) {
while (stack.length > 0) {
const open = stack.pop();
if (open.name === name) {
if (CONTROL_TAG_RE.test(name)) {
controls.push({ name, start: open.start, end: m.index });
}
break;
}
}
} else if (!selfClosing) {
stack.push({ name, start: m.index });
}
}
const sizesByControl = new Map();
for (const m of text.matchAll(DS_ICON_ELEMENT_RE)) {
const recipe = /recipe="([\w-]+)"/.exec(m[1])?.[1] ?? 'main';
if (recipe === 'illustration') continue;
const inner = controls
.filter((c) => c.start < m.index && c.end > m.index)
.sort((a, b) => b.start - a.start)[0];
if (!inner) continue;
const entry = sizesByControl.get(inner) ?? {
recipes: new Set(),
at: m.index,
};
entry.recipes.add(recipe);
sizesByControl.set(inner, entry);
}
const out = [];
for (const [control, { recipes }] of sizesByControl) {
if (recipes.size < 2) continue;
const line = lineOf(text, control.start);
if (exempt(lines, line)) continue;
out.push({
file: rel,
line,
message: `<${control.name}> mixes 12px and 16px icons. Use one recipe for every icon in a control.`,
});
}
return out;
}
const LUCIDE_TYPE_RE =
/^LucideIcon$|^ForwardRefExoticComponent<Omit<LucideProps/;
const ALLOWED_TAGS = new Set([...DS_TAGS, 'DsMorphIcon']);
/**
* JSX tags whose own type is a Lucide icon component, however the component
* was reached. Wrapper components that merely accept an `icon` prop are not
* icons themselves and are skipped.
*/
function typedLucideTags() {
const require = createRequire(import.meta.url);
const ts = require('typescript');
const configPath = join(REPO_ROOT, 'tsconfig.build.json');
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
REPO_ROOT
);
const program = ts.createProgram(parsed.fileNames, parsed.options);
const checker = program.getTypeChecker();
const isLucideType = (type) =>
(type.isUnion() ? type.types : [type]).some((t) =>
LUCIDE_TYPE_RE.test(checker.typeToString(t))
);
const out = [];
for (const sf of program.getSourceFiles()) {
const rel = relative(REPO_ROOT, sf.fileName).replaceAll('\\', '/');
if (
!rel.startsWith('src/') ||
!EXT.has(rel.slice(rel.lastIndexOf('.'))) ||
SKIP_PATHS.has(rel) ||
SKIP_PREFIXES.some((p) => rel.startsWith(p)) ||
SKIP_FILE_RE.test(rel)
) {
continue;
}
const lines = sf.text.split(/\r?\n/);
const visit = (node) => {
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
const name = node.tagName.getText(sf);
if (
/^[A-Z]|\./.test(name) &&
!ALLOWED_TAGS.has(name) &&
isLucideType(checker.getTypeAtLocation(node.tagName))
) {
const line =
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
if (!exempt(lines, line)) {
out.push({
file: rel,
line,
message: `Lucide icon <${name}> rendered directly. Use <DsIcon icon={${name}} />.`,
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
}
return out;
}
function main() {
const argv = process.argv.slice(2).filter((a) => a !== '--');
const explicit = argv
.filter((a) => !a.startsWith('-'))
.map((a) => resolve(REPO_ROOT, a))
.filter((p) => existsSync(p) && statSync(p).isFile())
.filter((p) => EXT.has(p.slice(p.lastIndexOf('.'))));
const fullRun = argv.length === 0;
const targets = fullRun ? [...walk(join(REPO_ROOT, 'src'))] : explicit;
const violations = targets.flatMap(checkFile);
if (fullRun) {
// The typed pass also covers direct imports; keep one finding per element.
const seen = new Set(violations.map((v) => `${v.file}:${v.line}`));
for (const v of typedLucideTags()) {
if (!seen.has(`${v.file}:${v.line}`)) violations.push(v);
}
}
if (violations.length === 0) {
console.log('check-icon-usage: OK (all icons render through DsIcon).');
process.exit(0);
}
console.error('check-icon-usage: icon style violations:\n');
for (const v of violations) {
console.error(` ${v.file}:${v.line}\n ${v.message}\n`);
}
console.error(
`Total: ${violations.length} finding(s). See docs/design-system/design.md §7 (Icons).`
);
process.exit(1);
}
main();