mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
parent
7bf67d3b1c
commit
831740411f
@@ -9,6 +9,7 @@ export * from './src/writeFileIfNotExists.ts';
|
||||
export * from './src/readAllMetadata.ts';
|
||||
export * from './src/getAllIconAliases.ts';
|
||||
export * from './src/readMetadata.ts';
|
||||
export * from './src/readGroups.ts';
|
||||
export * from './src/readSvgDirectory.ts';
|
||||
export * from './src/readSvg.ts';
|
||||
export * from './src/writeSvgFile.ts';
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { readMetadata } from './readMetadata.ts';
|
||||
import { resolveMetadataExtends } from './resolveMetadataExtends.ts';
|
||||
import { readGroups } from './readGroups.ts';
|
||||
import { type IconMetadata } from '../../build-icons/types.ts';
|
||||
|
||||
/**
|
||||
@@ -25,9 +26,11 @@ export const readAllMetadata = async (directory: string): Promise<Record<string,
|
||||
|
||||
const metadataMap = Object.fromEntries(metadata);
|
||||
|
||||
// Resolve extends references
|
||||
// Resolve extends and group references
|
||||
const groups = await readGroups();
|
||||
|
||||
try {
|
||||
return await resolveMetadataExtends(metadataMap);
|
||||
return await resolveMetadataExtends(metadataMap, { groups });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve metadata extends: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getCurrentDirPath } from './getCurrentDirPath.ts';
|
||||
|
||||
const currentDir = getCurrentDirPath(import.meta.url);
|
||||
|
||||
/**
|
||||
* Absolute path of `groups/`, resolved from this module's location rather than
|
||||
* from a caller-supplied directory, so every consumer (icons, lab, categories,
|
||||
* docs) reads the same groups.
|
||||
*/
|
||||
export const GROUPS_DIR = path.resolve(currentDir, '../../../groups');
|
||||
|
||||
/**
|
||||
* Reads the reusable groups of tags from `groups/`.
|
||||
*
|
||||
* Each `<name>.json` file becomes one entry, keyed by its file name without the
|
||||
* extension. Returns an empty object when the directory does not exist.
|
||||
*
|
||||
* @param {string} [directory] Directory to read the groups from.
|
||||
* @returns {object} A map of group name to its tags
|
||||
*/
|
||||
export const readGroups = async (
|
||||
directory: string = GROUPS_DIR,
|
||||
): Promise<Record<string, string[]>> => {
|
||||
let directoryContent: string[];
|
||||
|
||||
try {
|
||||
directoryContent = await fs.readdir(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const groupPromises = directoryContent
|
||||
.filter((file) => path.extname(file) === '.json')
|
||||
.map(async (file): Promise<[string, string[]]> => {
|
||||
const fileContent = await fs.readFile(path.join(directory, file), 'utf-8');
|
||||
const { tags } = JSON.parse(fileContent) as { tags?: string[] };
|
||||
const name = path.basename(file, '.json');
|
||||
|
||||
if (!Array.isArray(tags)) {
|
||||
throw new Error(`Group '${name}' is missing a 'tags' array.`);
|
||||
}
|
||||
|
||||
return [name, tags];
|
||||
});
|
||||
|
||||
return Object.fromEntries(await Promise.all(groupPromises));
|
||||
};
|
||||
@@ -2,15 +2,29 @@ import { type IconMetadata } from '../../build-icons/types.ts';
|
||||
import { mergeArrays } from './mergeArrays.ts';
|
||||
|
||||
const EXTENDS_PREFIX = '$extends:';
|
||||
const GROUP_PREFIX = '$group:';
|
||||
|
||||
const EXTENDABLE_FIELDS = ['tags', 'categories', 'contributors'] as const;
|
||||
type ExtendableField = (typeof EXTENDABLE_FIELDS)[number];
|
||||
|
||||
/** `$group:` markers describe the meaning of a modifier, which only makes sense for tags. */
|
||||
const GROUPABLE_FIELDS = ['tags'] as const satisfies readonly ExtendableField[];
|
||||
type GroupableField = (typeof GROUPABLE_FIELDS)[number];
|
||||
|
||||
const isExtendsMarker = (value: string): boolean => value.startsWith(EXTENDS_PREFIX);
|
||||
const getExtendsTarget = (value: string): string => value.slice(EXTENDS_PREFIX.length);
|
||||
|
||||
const isGroupMarker = (value: string): boolean => value.startsWith(GROUP_PREFIX);
|
||||
const getGroupTarget = (value: string): string => value.slice(GROUP_PREFIX.length);
|
||||
|
||||
const isGroupableField = (field: ExtendableField): field is GroupableField =>
|
||||
(GROUPABLE_FIELDS as readonly ExtendableField[]).includes(field);
|
||||
|
||||
export type Groups = Record<string, string[]>;
|
||||
|
||||
interface ResolveOptions {
|
||||
validateReferences?: boolean;
|
||||
groups?: Groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,19 +32,22 @@ interface ResolveOptions {
|
||||
* Recursively resolves extends chains and merges inherited properties
|
||||
* Inline `$extends:<icon-name>` markers inside the tags/categories/contributors
|
||||
* arrays are expanded in place with the referenced icon's resolved values.
|
||||
* Inline `$group:<group-name>` markers inside the tags array are expanded in
|
||||
* place with the tags of the matching `groups/<group-name>.json`.
|
||||
*/
|
||||
export const resolveMetadataExtends = async (
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
options: ResolveOptions = {},
|
||||
): Promise<Record<string, IconMetadata>> => {
|
||||
const { validateReferences = true } = options;
|
||||
const { validateReferences = true, groups = {} } = options;
|
||||
const visited = new Set<string>();
|
||||
const resolved: Record<string, IconMetadata> = {};
|
||||
|
||||
// Validate all extends references first if requested
|
||||
// Validate all extends and group references first if requested
|
||||
if (validateReferences) {
|
||||
for (const [iconName, metadata] of Object.entries(allMetadata)) {
|
||||
validateExtendsReferences(iconName, metadata, allMetadata);
|
||||
validateGroupReferences(iconName, metadata, groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +55,7 @@ export const resolveMetadataExtends = async (
|
||||
// are returned from the cache by resolveIconExtends, so every icon still ends
|
||||
// up in the resulting map.
|
||||
for (const iconName of Object.keys(allMetadata)) {
|
||||
resolved[iconName] = resolveIconExtends(iconName, allMetadata, visited, new Set());
|
||||
resolved[iconName] = resolveIconExtends(iconName, allMetadata, visited, new Set(), groups);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
@@ -53,6 +70,7 @@ export const resolveIconExtends = (
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
visited: Set<string>,
|
||||
recursionStack: Set<string>,
|
||||
groups: Groups = {},
|
||||
): IconMetadata => {
|
||||
// Check for circular dependency
|
||||
if (recursionStack.has(iconName)) {
|
||||
@@ -71,8 +89,9 @@ export const resolveIconExtends = (
|
||||
|
||||
recursionStack.add(iconName);
|
||||
|
||||
// Expand inline `$extends:<icon-name>` markers within a single field, keeping
|
||||
// literal values in place and de-duplicating the result (first occurrence wins).
|
||||
// Expand inline `$extends:<icon-name>` and `$group:<group-name>` markers within
|
||||
// a single field, keeping literal values in place and de-duplicating the result
|
||||
// (first occurrence wins).
|
||||
const resolveField = (field: ExtendableField): string[] => {
|
||||
const source = (metadata[field] as string[] | undefined) ?? [];
|
||||
const expanded: string[] = [];
|
||||
@@ -83,8 +102,15 @@ export const resolveIconExtends = (
|
||||
validateParentExists(iconName, parentName, allMetadata);
|
||||
validateNotSelfReference(iconName, parentName);
|
||||
|
||||
const parent = resolveIconExtends(parentName, allMetadata, visited, recursionStack);
|
||||
const parent = resolveIconExtends(parentName, allMetadata, visited, recursionStack, groups);
|
||||
expanded.push(...((parent[field] as string[] | undefined) ?? []));
|
||||
} else if (isGroupMarker(entry)) {
|
||||
validateGroupField(iconName, entry, field);
|
||||
|
||||
const groupName = getGroupTarget(entry);
|
||||
validateGroupExists(iconName, groupName, groups);
|
||||
|
||||
expanded.push(...(groups[groupName] as string[]));
|
||||
} else {
|
||||
expanded.push(entry);
|
||||
}
|
||||
@@ -129,6 +155,25 @@ export const validateExtendsReferences = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all inline group markers sit in a field that supports them and
|
||||
* point to an existing group
|
||||
*/
|
||||
export const validateGroupReferences = (
|
||||
iconName: string,
|
||||
metadata: IconMetadata,
|
||||
groups: Groups,
|
||||
): void => {
|
||||
for (const field of EXTENDABLE_FIELDS) {
|
||||
for (const entry of (metadata[field] as string[] | undefined) ?? []) {
|
||||
if (!isGroupMarker(entry)) continue;
|
||||
|
||||
validateGroupField(iconName, entry, field);
|
||||
validateGroupExists(iconName, getGroupTarget(entry), groups);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a parent exists in metadata
|
||||
*/
|
||||
@@ -150,3 +195,29 @@ function validateNotSelfReference(iconName: string, parentName: string): void {
|
||||
throw new Error(`Icon '${iconName}' cannot extend from itself`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a group marker is used in a field that supports groups
|
||||
*/
|
||||
function validateGroupField(iconName: string, entry: string, field: ExtendableField): void {
|
||||
if (!isGroupableField(field)) {
|
||||
throw new Error(
|
||||
`Icon '${iconName}' uses '${entry}' in '${field}'. Groups are only allowed in '${GROUPABLE_FIELDS.join("', '")}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a group exists
|
||||
*/
|
||||
function validateGroupExists(iconName: string, groupName: string, groups: Groups): void {
|
||||
if (!groups[groupName]) {
|
||||
const available = Object.keys(groups);
|
||||
|
||||
throw new Error(
|
||||
`Icon '${iconName}' uses non-existent group '${groupName}'. Available groups: ${
|
||||
available.length > 0 ? available.join(', ') : '(none)'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { readSvgDirectory } from '@lucide/helpers';
|
||||
import { readSvgDirectory, readGroups } from '@lucide/helpers';
|
||||
import { type IconMetadata } from '../types.ts';
|
||||
import { resolveMetadataExtends } from '../../build-helpers/src/resolveMetadataExtends.ts';
|
||||
|
||||
@@ -18,8 +18,10 @@ async function getIconMetaData(iconDirectory: string): Promise<Record<string, Ic
|
||||
|
||||
const metadataMap = Object.fromEntries(aliasesEntries);
|
||||
|
||||
// Resolve extends references
|
||||
return await resolveMetadataExtends(metadataMap);
|
||||
// Resolve extends and group references
|
||||
const groups = await readGroups();
|
||||
|
||||
return await resolveMetadataExtends(metadataMap, { groups });
|
||||
}
|
||||
|
||||
export default getIconMetaData;
|
||||
|
||||
Reference in New Issue
Block a user