Files
lucide/scripts/checkIconsAndCategories.mts
T
Eric FennisClaude Opus 5.5github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
831740411f feat(metadata|docs): reusable tag groups with $group: (#5037)
* 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>
2026-10-29 17:29:58 +01:00

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);
}