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:
Eric Fennis
2026-10-29 17:29:58 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
parent 7bf67d3b1c
commit 831740411f
25 changed files with 375 additions and 54 deletions
+1
View File
@@ -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';
+5 -2
View File
@@ -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)}`,
+53
View File
@@ -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)'
}`,
);
}
}
+5 -3
View File
@@ -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;