mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
* feat(metadata): reusable tag groups with `$group:` A `$group:<group-name>` marker in `tags` is replaced in place with the tags of `groups/<group-name>.json`: a small set of tags for what a variant suffix means, such as `-minus` → remove, delete. - Read groups in `readGroups` and resolve markers next to `$extends:`, failing on unknown groups and on `$group:` outside `tags` - Add `group.schema.json` and lint, spell-check and format `groups/*.json` - `pnpm checkIcons` also fails on literals a group already provides, and warns about icons that list a group's tags by hand and unused groups - Add the `minus`, `plus` and `x` groups and use them in `alarm-clock-minus`, `file-minus`, `file-plus` and `file-x` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix group checker * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * feat(metadata): reusable tag groups with `$group:` A `$group:<group-name>` marker in `tags` is replaced in place with the tags of `groups/<group-name>.json`: a small set of tags for what a variant suffix means, such as `-minus` → remove, delete. - Read groups in `readGroups` and resolve markers next to `$extends:`, failing on unknown groups and on `$group:` outside `tags` - Add `group.schema.json` and lint, spell-check and format `groups/*.json` - `pnpm checkIcons` also fails on literals a group already provides, and warns about icons that list a group's tags by hand and unused groups - Add the `minus`, `plus` and `x` groups and use them in `alarm-clock-minus`, `file-minus`, `file-plus` and `file-x` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix group checker * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * feat(metadata): reusable tag groups with `$group:` A `$group:<group-name>` marker in `tags` is replaced in place with the tags of `groups/<group-name>.json`: a small set of tags for what a variant suffix means, such as `-minus` → remove, delete. - Read groups in `readGroups` and resolve markers next to `$extends:`, failing on unknown groups and on `$group:` outside `tags` - Add `group.schema.json` and lint, spell-check and format `groups/*.json` - `pnpm checkIcons` also fails on literals a group already provides, and warns about icons that list a group's tags by hand and unused groups - Add the `minus`, `plus` and `x` groups and use them in `alarm-clock-minus`, `file-minus`, `file-plus` and `file-x` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix group checker * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * feat(metadata): reusable tag groups with `$group:` A `$group:<group-name>` marker in `tags` is replaced in place with the tags of `groups/<group-name>.json`: a small set of tags for what a variant suffix means, such as `-minus` → remove, delete. - Read groups in `readGroups` and resolve markers next to `$extends:`, failing on unknown groups and on `$group:` outside `tags` - Add `group.schema.json` and lint, spell-check and format `groups/*.json` - `pnpm checkIcons` also fails on literals a group already provides, and warns about icons that list a group's tags by hand and unused groups - Add the `minus`, `plus` and `x` groups and use them in `alarm-clock-minus`, `file-minus`, `file-plus` and `file-x` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix group checker * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
import path from 'path';
|
|
import {
|
|
readSvgDirectory,
|
|
getCurrentDirPath,
|
|
readAllMetadata,
|
|
readMetadata,
|
|
readGroups,
|
|
} from '../tools/build-helpers/helpers.ts';
|
|
import { type IconMetadata } from '../tools/build-icons/types.ts';
|
|
|
|
const currentDir = getCurrentDirPath(import.meta.url);
|
|
const ICONS_DIR = path.resolve(currentDir, '../icons');
|
|
let icons: Record<string, IconMetadata>;
|
|
|
|
try {
|
|
icons = await readAllMetadata(ICONS_DIR) as Record<string, IconMetadata>;
|
|
} catch (error) {
|
|
console.error(`Failed to read icon metadata: ${error instanceof Error ? error.message : String(error)}`);
|
|
console.error('This may be due to invalid extends or group references, or circular dependencies.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const CATEGORIES_DIR = path.resolve(currentDir, '../categories');
|
|
const categories = await readAllMetadata(CATEGORIES_DIR) as Record<string, {
|
|
icon: string;
|
|
name: string;
|
|
}>;;
|
|
|
|
console.log('Reading all icons');
|
|
|
|
const svgFiles = await readSvgDirectory(ICONS_DIR);
|
|
const iconNames = svgFiles.map((icon) => icon.split('.')[0]);
|
|
|
|
let error = false;
|
|
|
|
iconNames.forEach((iconName) => {
|
|
if (typeof icons[iconName] === 'undefined') {
|
|
console.error(`'${iconName}.svg' does not have a matching JSON file.`);
|
|
error = true;
|
|
}
|
|
});
|
|
|
|
Object.keys(icons).forEach((iconName) => {
|
|
const icon = icons[iconName];
|
|
if (iconNames.indexOf(iconName) === -1) {
|
|
console.error(`'${iconName}.svg' does not exist.`);
|
|
error = true;
|
|
}
|
|
icon.categories?.forEach((categoryName) => {
|
|
if (typeof categories[categoryName] === 'undefined') {
|
|
console.error(`Icon '${iconName}' refers to the non-existing category '${categoryName}'.`);
|
|
error = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
const EXTENDS_PREFIX = '$extends:';
|
|
const GROUP_PREFIX = '$group:';
|
|
|
|
const MARKED_FIELDS = ['tags', 'categories', 'contributors'] as const;
|
|
type MarkedField = (typeof MARKED_FIELDS)[number];
|
|
|
|
const FIELD_LABELS: Record<MarkedField, string> = {
|
|
tags: 'tag',
|
|
categories: 'category',
|
|
contributors: 'contributor',
|
|
};
|
|
|
|
const isMarker = (entry: string) =>
|
|
entry.startsWith(EXTENDS_PREFIX) || entry.startsWith(GROUP_PREFIX);
|
|
|
|
const groups = await readGroups();
|
|
const usedGroups = new Set<string>();
|
|
|
|
// `readAllMetadata` expands the markers, so the duplicate checks below read the
|
|
// JSON as it is written on disk to see which entries are literals.
|
|
const iconJsonFiles = await readSvgDirectory(ICONS_DIR, '.json');
|
|
const rawIcons = Object.fromEntries(
|
|
await Promise.all(
|
|
iconJsonFiles.map(async (file) => [
|
|
path.basename(file, '.json'),
|
|
(await readMetadata(file, ICONS_DIR)) as Partial<Record<MarkedField, string[]>>,
|
|
]),
|
|
),
|
|
) as Record<string, Partial<Record<MarkedField, string[]>>>;
|
|
|
|
/** The values a single marker contributes to `field`. */
|
|
const resolveMarker = (marker: string, field: MarkedField): string[] => {
|
|
if (marker.startsWith(GROUP_PREFIX)) {
|
|
const groupName = marker.slice(GROUP_PREFIX.length);
|
|
usedGroups.add(groupName);
|
|
|
|
return groups[groupName] ?? [];
|
|
}
|
|
|
|
const parentName = marker.slice(EXTENDS_PREFIX.length);
|
|
|
|
return (icons[parentName]?.[field] as string[] | undefined) ?? [];
|
|
};
|
|
|
|
Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => {
|
|
MARKED_FIELDS.forEach((field) => {
|
|
const entries = rawMetadata[field] ?? [];
|
|
const literals = new Set(entries.filter((entry) => !isMarker(entry)));
|
|
const seenMarkers = new Set<string>();
|
|
|
|
entries.filter(isMarker).forEach((marker) => {
|
|
if (seenMarkers.has(marker)) {
|
|
console.error(
|
|
`Icon '${iconName}': '${marker}' is listed twice in '${field}' — remove the duplicate marker.`,
|
|
);
|
|
error = true;
|
|
return;
|
|
}
|
|
seenMarkers.add(marker);
|
|
|
|
resolveMarker(marker, field)
|
|
.filter((value) => literals.has(value))
|
|
.forEach((value) => {
|
|
console.error(
|
|
`Icon '${iconName}': ${FIELD_LABELS[field]} '${value}' is already provided by ${marker} — remove the literal.`,
|
|
);
|
|
error = true;
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Check if the icon has literal tags that could be replaced by group references.
|
|
Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => {
|
|
const tags = rawMetadata.tags ?? [];
|
|
const unreferencedGroups = Object.entries(groups).filter(
|
|
([groupName]) => !tags.includes(`${GROUP_PREFIX}${groupName}`),
|
|
);
|
|
|
|
tags
|
|
.filter((entry) => !isMarker(entry))
|
|
.forEach((tag) => {
|
|
const matchingMarkers = unreferencedGroups
|
|
.filter(([, groupTags]) => groupTags.includes(tag))
|
|
.map(([groupName]) => `"${GROUP_PREFIX}${groupName}"`);
|
|
|
|
if (matchingMarkers.length > 0) {
|
|
console.error(
|
|
`Icon '${iconName}': tag '${tag}' is in ${matchingMarkers.join(', ')} — consider using the group instead.`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
Object.keys(categories).forEach((categoryName) => {
|
|
const category = categories[categoryName];
|
|
if (!category?.icon) {
|
|
console.error(`Category '${categoryName}' does not use an icon '${category.icon}'.`);
|
|
error = true;
|
|
} else if (typeof icons[category.icon] === 'undefined') {
|
|
console.error(`Category '${categoryName}' uses the non-existing icon '${category.icon}'.`);
|
|
error = true;
|
|
}
|
|
});
|
|
|
|
Object.keys(groups)
|
|
.filter((groupName) => !usedGroups.has(groupName))
|
|
.forEach((groupName) => {
|
|
console.warn(`Group '${groupName}' is not used by any icon.`);
|
|
});
|
|
|
|
if (error) {
|
|
console.error('At least one error in icon JSONs prevents from committing changes.');
|
|
process.exit(1);
|
|
}
|