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> * feat(icons): apply tag groups to variant icons Add 23 groups for common variant suffixes and use them in 89 icons, removing the literal tags a group now provides. No icon loses a resolved tag; 65 icons gain tags from their group. Groups: alert, check, clock, code, cog, currency, dot, heart, key, lock, off, pause, pen, percent, play, question-mark, search, slash, sparkles, star, stop, sync, user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add favorite * feat(icons): extend base icon metadata in variant icons Use `$extends:<base>` in 703 variant icons whose tags or categories already include every value of their base icon, and remove the 1952 tags and 1058 categories the base now provides. The base is the longest prefix of the icon name that is an icon, e.g. `clipboard-pen-line` extends `clipboard-pen`. - `tags` extend their base in 417 icons, `categories` in 675 - Only applied where the base adds nothing new, so the resolved tags and categories of every icon are unchanged - `contributors` are left as they are Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBLsKpZMoKKKVeibcuRXnC * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Karsa <contact@karsa.org> * Apply feedback * Updated groups * Apply updates * Apply more extends and groups * Stricter checks * Remove more duplicates * Update tags * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix tags * 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> * feat(icons): apply tag groups to variant icons Add 23 groups for common variant suffixes and use them in 89 icons, removing the literal tags a group now provides. No icon loses a resolved tag; 65 icons gain tags from their group. Groups: alert, check, clock, code, cog, currency, dot, heart, key, lock, off, pause, pen, percent, play, question-mark, search, slash, sparkles, star, stop, sync, user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add favorite * feat(icons): extend base icon metadata in variant icons Use `$extends:<base>` in 703 variant icons whose tags or categories already include every value of their base icon, and remove the 1952 tags and 1058 categories the base now provides. The base is the longest prefix of the icon name that is an icon, e.g. `clipboard-pen-line` extends `clipboard-pen`. - `tags` extend their base in 417 icons, `categories` in 675 - Only applied where the base adds nothing new, so the resolved tags and categories of every icon are unchanged - `contributors` are left as they are Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBLsKpZMoKKKVeibcuRXnC * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Karsa <contact@karsa.org> * Apply feedback * Updated groups * Apply updates * Apply more extends and groups * Stricter checks * Remove more duplicates * Update tags * Apply batched suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix tags --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Karsa <contact@karsa.org> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import fs from 'fs/promises';
|
|
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;
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// An icon that lists every tag of a group as literals describes that group's
|
|
// meaning by hand, so it should reference the group instead.
|
|
Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => {
|
|
const tags = rawMetadata.tags ?? [];
|
|
const literals = new Set(tags.filter((entry) => !isMarker(entry)));
|
|
|
|
Object.entries(groups)
|
|
.filter(([groupName]) => !tags.includes(`${GROUP_PREFIX}${groupName}`))
|
|
.filter(([, groupTags]) => groupTags.every((tag) => literals.has(tag)))
|
|
.forEach(([groupName, groupTags]) => {
|
|
console.error(
|
|
`Icon '${iconName}': tags ${groupTags.map((tag) => `'${tag}'`).join(', ')} are all in group '${groupName}' — use "${GROUP_PREFIX}${groupName}" instead.`,
|
|
);
|
|
error = true;
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|
|
});
|
|
|
|
// The schema lists every group as an enum so editors can autocomplete the
|
|
// markers; it has to match the files in `groups/`.
|
|
const iconSchema = JSON.parse(
|
|
await fs.readFile(path.resolve(currentDir, '../icon.schema.json'), 'utf-8'),
|
|
);
|
|
const schemaGroups = new Set<string>(
|
|
(iconSchema.$defs.groupMarker.enum as string[]).map((marker) =>
|
|
marker.slice(GROUP_PREFIX.length),
|
|
),
|
|
);
|
|
|
|
Object.keys(groups)
|
|
.filter((groupName) => !schemaGroups.has(groupName))
|
|
.forEach((groupName) => {
|
|
console.error(`Group '${groupName}' is missing in icon.schema.json.`);
|
|
error = true;
|
|
});
|
|
|
|
[...schemaGroups]
|
|
.filter((groupName) => typeof groups[groupName] === 'undefined')
|
|
.forEach((groupName) => {
|
|
console.error(
|
|
`'${GROUP_PREFIX}${groupName}' in icon.schema.json refers to the non-existing group 'groups/${groupName}.json'.`,
|
|
);
|
|
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);
|
|
}
|