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
+43 -2
View File
@@ -4,6 +4,7 @@ import {
getCurrentDirPath,
readAllMetadata,
readMetadata,
readGroups,
} from '../tools/build-helpers/helpers.ts';
import { type IconMetadata } from '../tools/build-icons/types.ts';
@@ -15,7 +16,7 @@ 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 references or circular dependencies.');
console.error('This may be due to invalid extends or group references, or circular dependencies.');
process.exit(1);
}
@@ -54,6 +55,7 @@ Object.keys(icons).forEach((iconName) => {
});
const EXTENDS_PREFIX = '$extends:';
const GROUP_PREFIX = '$group:';
const MARKED_FIELDS = ['tags', 'categories', 'contributors'] as const;
type MarkedField = (typeof MARKED_FIELDS)[number];
@@ -64,7 +66,11 @@ const FIELD_LABELS: Record<MarkedField, string> = {
contributors: 'contributor',
};
const isMarker = (entry: string) => entry.startsWith(EXTENDS_PREFIX);
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.
@@ -80,6 +86,13 @@ const rawIcons = Object.fromEntries(
/** 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) ?? [];
@@ -113,6 +126,28 @@ Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => {
});
});
// 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) {
@@ -124,6 +159,12 @@ Object.keys(categories).forEach((categoryName) => {
}
});
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);
+1 -1
View File
@@ -8,7 +8,7 @@ import process from 'process';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
const PATTERNS = ['icons/*.json', 'categories/*.json'];
const PATTERNS = ['icons/*.json', 'categories/*.json', 'groups/*.json'];
// cspell only exports its bin for `import`, so resolve it the ESM way.
const cspellBin = fileURLToPath(import.meta.resolve('cspell/bin.mjs'));