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
@@ -0,0 +1,29 @@
---
applyTo: "groups/*.json"
---
# Groups
A group is a small, reusable set of tags for **one icon variant** — the modifier suffix in an icon name, such as `-plus`, `-check` or `-off`. Icons reference a group with a `"$group:<group-name>"` marker inside their `tags` array, and the marker is replaced at build time with the group's tags.
The file name without `.json` is the group name: the variant suffix it belongs to (`plus` for `file-plus`, `question-mark` for `file-question-mark`), in lowercase kebab-case. Each file has a `$schema`, a one-line `description` saying which meaning the group covers, and a non-empty `tags` array.
```json
{
"$schema": "../group.schema.json",
"description": "Action: removing or deleting something",
"tags": ["remove", "delete"]
}
```
## Rules
- **One group per variant.** Name the group after the variant suffix (`plus`, `minus`, `check`, `cog`), so `file-plus` uses `$group:plus` and `alarm-clock-minus` uses `$group:minus`.
- **Different suffixes with the same meaning share one group.** Do not create a second group for a synonym: `-warning` and `-exclamation-point` use `$group:alert`. When a meaning is spread over many suffixes, name the group after the meaning instead: every currency suffix (`-euro`, `-dollar-sign`, `-japanese-yen`…) uses `$group:currency`.
- **Tags describe what the variant means, not its glyph.** `plus` holds `add`, `new`, `create`; `cog` holds `settings`, `gear`. Do not put the suffix itself (`plus`, `cog`, `lock`) in a group: tags must not repeat the icon name.
- **Keep groups small and generic.** Only tags that hold for *every* icon that uses the group. If a tag is true for just some of them, it belongs on those icons as a literal.
- **Not every icon with the suffix uses the group.** `-off` means "disabled" on `wifi-off` but "allergy free" on `wheat-off`, and `-x` means "delete" on `file-x` but "mute" on `volume-x`. Only icons where the variant carries the group's meaning use the marker; the others keep literal tags.
- **Do not add a group before it is used.** `pnpm checkIcons` warns about groups no icon references.
- **Groups do not nest.** A group's `tags` are literal tags only — no `$extends:` or `$group:` markers.
- Tags are lowercase, and follow the same conventions as icon tags.
Run `pnpm run lint:json:groups` to validate the files against the schema, and `pnpm checkIcons` to check how icons use them.
+25 -3
View File
@@ -25,7 +25,7 @@ Some icons are variants of another icon (for example `alarm-clock-minus` is an `
```jsonc
{
"tags": ["$extends:alarm-clock", "remove"],
"tags": ["$extends:alarm-clock", "$group:minus"],
"categories": ["$extends:alarm-clock"],
"contributors": ["$extends:alarm-clock", "some-user"]
}
@@ -35,14 +35,36 @@ When checking or adding a `$extends:` marker:
- A marker must be exactly `"$extends:"` followed by the name of an **existing** icon — one that has its own `icons/<icon-name>.json` and `icons/<icon-name>.svg`. The icon name is the file name without the `.json` extension, written in lowercase kebab-case. Referencing a non-existent icon is invalid.
- Markers are only supported inside the `tags`, `categories`, and `contributors` arrays. They are not a separate property.
- Only use `$extends:` for the icon's **base icon** — the thing the icon _is_ (`file-plus` is a `file`, `alarm-clock-minus` is an `alarm-clock`). Never extend a **modifier** icon such as `plus`, `minus`, `x` or `check`: their tags describe that glyph on its own (`minus` carries `divider`, `hr`, `markdown`, `separator`…), which makes no sense on an alarm clock. Add the modifier's meaning as literal tags instead.
- Only use `$extends:` for the icon's **base icon** — the thing the icon _is_ (`file-plus` is a `file`, `alarm-clock-minus` is an `alarm-clock`). Never extend a **modifier** icon such as `plus`, `minus`, `x` or `check`: their tags describe that glyph on its own (`minus` carries `divider`, `hr`, `markdown`, `separator`…), which makes no sense on an alarm clock. Use a group for the modifier's meaning instead.
- An icon may not extend itself, and circular chains (A extends B, B extends A) are not allowed.
- Ordering is preserved: inherited values are inserted at the marker's position, so place `$extends:` markers where the inherited values should appear (conventionally first, before the icon's own literal values).
- Run `pnpm checkIcons` to validate all references. It fails on missing icons, self-references, and circular dependencies. Do not assume a marker is valid without confirming the target icon exists.
## Groups
`$extends:` says what an icon **is**; `$group:` says what its variant suffix (`-plus`, `-check`, `-off`…) **means**. A `"$group:<group-name>"` marker inside `tags` is replaced, in place, with the tags of `groups/<group-name>.json`.
```jsonc
{
// `file-minus` is a file, and its `-minus` variant means "remove"
"tags": ["$extends:file", "$group:minus", "erase"],
"categories": ["$extends:file"]
}
```
When checking or adding a `$group:` marker:
- A marker must be exactly `"$group:"` followed by the name of an existing file in `groups/`, without the `.json` extension, in lowercase kebab-case.
- `$group:` is allowed **only** inside `tags`. It is not valid in `categories` or `contributors`.
- Groups are named after the variant suffix: `file-plus` uses `$group:plus`, `file-question-mark` uses `$group:question-mark`. Suffixes with the same meaning share a group: `mail-warning` uses `$group:alert`, and every currency suffix uses `$group:currency`.
- The same suffix can mean different things in different contexts — `-off` means "disabled" on `wifi-off` but "allergy free" on `wheat-off` — so only use the group when the icon carries the group's meaning, and keep context-specific tags as literals.
- Do not repeat a tag a group already provides, and do not use the same group twice in one array.
- Conventional order: the `$extends:` base icon first, then `$group:` markers, then the icon's own literal tags.
- Run `pnpm checkIcons` to validate the markers. It fails on unknown groups, on `$group:` outside `tags`, and on literals that a marker already provides.
## There should not be duplicates
Never list a value that a `$extends:` marker already provides. Before adding a literal `tag`, `category`, or `contributor`, look at the referenced icon's metadata: if that icon already contains the value, do not also add it as a literal entry in the same array — the inherited value already covers it. For example, if `alarm-clock` already has the tag `morning`, an icon that uses `"$extends:alarm-clock"` in its `tags` must **not** also list `"morning"`.
Never list a value that a `$extends:` or `$group:` marker already provides. Before adding a literal `tag`, `category`, or `contributor`, look at the referenced icon's metadata: if that icon already contains the value, do not also add it as a literal entry in the same array — the inherited value already covers it. For example, if `alarm-clock` already has the tag `morning`, an icon that uses `"$extends:alarm-clock"` in its `tags` must **not** also list `"morning"`.
Likewise, do not reference the same icon more than once in a single field (no duplicate `$extends:` markers), and do not repeat literal values. The `tags`, `categories`, and `contributors` arrays must contain only unique entries (`uniqueItems` in `icon.schema.json`), and after extends resolution the final arrays must not contain any duplicates.
+1
View File
@@ -23,6 +23,7 @@
- any-glob-to-any-file:
- 'icons/*.json'
- categories/*
- groups/*
# For changes or added icons
🎨 icon:
+1
View File
@@ -8,6 +8,7 @@ on:
paths:
- 'icons/*'
- 'lab/*'
- 'groups/*'
permissions: {}
@@ -135,16 +135,20 @@ Write metadata for the specific variant:
When a request covers several icons, assign each use case only to the icon it describes. Do not copy metadata to icons it does not describe.
## Reusing metadata: `$extends:`
## Reusing metadata: `$extends:` and `$group:`
Variant icons do not have to repeat the metadata of the icon they are built on. A `$extends:<icon-name>` marker means **is a**: at build time it is replaced, in place, by the resolved `tags`, `categories` or `contributors` of that icon.
Variant icons do not have to repeat the metadata they share with other icons. Two inline markers are resolved at build time and replaced, in place, by the values they stand for.
`alarm-clock-minus` is an alarm clock:
`$extends:<icon-name>` means **is a**. It pulls in the resolved `tags`, `categories` or `contributors` of the icon the variant is built on.
`$group:<group-name>` means **does this**. It pulls in the tags of `groups/<group-name>.json`, a small reusable set of tags for one icon variant, named after its suffix (`plus`, `minus`, `check`, `off`…). It is only allowed in `tags`.
`file-minus` is a file, and its `-minus` variant means "remove":
```json
{
"tags": ["$extends:alarm-clock", "remove"],
"categories": ["$extends:alarm-clock"]
"tags": ["$extends:file", "$group:minus", "erase"],
"categories": ["$extends:file"]
}
```
@@ -152,14 +156,14 @@ which resolves to:
```json
{
"tags": ["morning", "remove"],
"categories": ["devices", "notifications", "time"]
"tags": ["document", "remove", "delete", "erase"],
"categories": ["files"]
}
```
Use `$extends:` only for the **base icon** — the thing the icon is. Do not extend a modifier icon such as `plus`, `minus`, `x` or `check`: their tags describe that glyph on its own (`minus` carries `divider`, `hr`, `markdown`…), which is wrong on a file or an alarm clock. Add the modifier's meaning as the icon's own tags instead.
Use `$extends:` only for the **base icon** — the thing the icon is. Do not extend a modifier icon such as `plus`, `minus`, `x` or `check`: their tags describe that glyph on its own (`minus` carries `divider`, `hr`, `markdown`…), which is wrong on a file or an alarm clock. Reach for the variant's group instead (`$group:minus`), whose tags describe what the variant means rather than the glyph.
Put the marker first, before the icon's own values, and never repeat a value the marker already provides — `pnpm checkIcons` fails on that.
Order the entries base icon first, then groups, then the icon's own tags, and never repeat a value a marker already provides — `pnpm checkIcons` fails on that.
## Aliases
@@ -175,7 +179,7 @@ Before submitting metadata changes, validate the icon JSON files:
pnpm run lint:json:icons
```
`$extends:` markers are checked separately:
`$extends:` and `$group:` markers are checked separately, together with the groups themselves:
```sh
pnpm checkIcons
@@ -198,5 +202,5 @@ Before opening a pull request, confirm that:
- Tags are lowercase and useful for search.
- Categories are allowed by `icon.schema.json`.
- Metadata is specific to the icon and its variants.
- Reused metadata uses `$extends:` for the base icon, with no repeated values.
- Reused metadata uses `$extends:` for the base icon and `$group:` for the modifier's meaning, with no repeated values.
- `pnpm run lint:json:icons` and `pnpm checkIcons` pass.
+7 -1
View File
@@ -155,7 +155,7 @@ export default defineConfig([
},
},
{
files: ['icons/*.json', 'categories/*.json'],
files: ['icons/*.json', 'categories/*.json', 'groups/*.json'],
languageOptions: {
parser: jsoncParser,
},
@@ -175,4 +175,10 @@ export default defineConfig([
'json-schema/no-invalid': ['error', { schema: 'category.schema.json' }],
},
},
{
files: ['groups/*.json'],
rules: {
'json-schema/no-invalid': ['error', { schema: 'group.schema.json' }],
},
},
]);
+40
View File
@@ -0,0 +1,40 @@
{
"$id": "https://lucide.dev/group.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
"https://json-schema.org/draft/2020-12/vocab/validation": true,
"https://json-schema.org/draft/2020-12/vocab/meta-data": true,
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
"https://json-schema.org/draft/2020-12/vocab/content": true
},
"title": "Lucide Icons group schema",
"type": "object",
"additionalProperties": false,
"required": ["$schema", "description", "tags"],
"properties": {
"$schema": {
"type": "string"
},
"description": {
"type": "string",
"minLength": 1,
"description": "One line describing which meaning this group covers."
},
"tags": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"not": {
"pattern": "^\\$[a-zA-Z]"
}
},
"minItems": 1,
"uniqueItems": true
}
},
"description": "A JSON Schema for reusable groups defined by Lucide Icons."
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../group.schema.json",
"description": "Action: removing or deleting something",
"tags": [
"remove",
"delete"
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../group.schema.json",
"description": "Action: adding or creating something",
"tags": [
"add",
"new",
"create"
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../group.schema.json",
"description": "Action: removing, deleting or cancelling something",
"tags": [
"remove",
"delete",
"cancel"
]
}
+1 -1
View File
@@ -129,7 +129,7 @@
"pattern": "^\\$[a-zA-Z]"
},
"then": {
"pattern": "^\\$extends:[a-z0-9]+(-[a-z0-9]+)*$"
"pattern": "^\\$(extends|group):[a-z0-9]+(-[a-z0-9]+)*$"
}
},
"minItems": 1,
+9 -3
View File
@@ -6,13 +6,19 @@
"jguddas",
"karsa-mistmere"
],
"use-cases": [],
"use-cases": [
"Removing a scheduled wake-up alert from a clock app",
"Deleting an existing alarm from a reminder or scheduling interface"
],
"tags": [
"$extends:alarm-clock",
"remove"
"$group:minus",
"reminder",
"unschedule"
],
"categories": [
"$extends:alarm-clock"
"$extends:alarm-clock",
"notifications"
],
"aliases": [
{
+9 -5
View File
@@ -5,14 +5,18 @@
"ericfennis",
"danielbayley"
],
"use-cases": [],
"use-cases": [
"Removing a document from a file manager",
"Removing an attachment from a form"
],
"tags": [
"delete",
"remove",
"$extends:file",
"$group:minus",
"erase",
"document"
"discard",
"exclude"
],
"categories": [
"files"
"$extends:file"
]
}
+8 -6
View File
@@ -5,14 +5,16 @@
"ericfennis",
"danielbayley"
],
"use-cases": [],
"use-cases": [
"Creating a new document in a text editor",
"Adding a file to a project or workspace",
"Starting file creation in a file manager"
],
"tags": [
"add",
"create",
"new",
"document"
"$extends:file",
"$group:plus"
],
"categories": [
"files"
"$extends:file"
]
}
+9 -6
View File
@@ -4,14 +4,17 @@
"ericfennis",
"danielbayley"
],
"use-cases": [],
"use-cases": [
"Removing a document from a file manager",
"Canceling a file operation or selection",
"Marking a document as deleted or unavailable"
],
"tags": [
"lost",
"delete",
"remove",
"document"
"$extends:file",
"$group:x",
"lost"
],
"categories": [
"files"
"$extends:file"
]
}
+5
View File
@@ -15,6 +15,11 @@ const config = {
`cspell --no-progress ${filenames.join(' ')}`,
`prettier --write ${filenames.join(' ')}`,
],
'groups/*.json': (filenames) => [
`eslint ${filenames.join(' ')}`,
`cspell --no-progress ${filenames.join(' ')}`,
`prettier --write ${filenames.join(' ')}`,
],
};
export default config;
+4 -3
View File
@@ -36,7 +36,7 @@
"suggest:metadata:debug": "pnpm --filter @lucide/suggest-metadata-action debug",
"suggest:metadata:watch": "pnpm --filter @lucide/suggest-metadata-action watch",
"postinstall": "husky",
"lint:es": "eslint . --ignore-pattern 'icons/*.json' --ignore-pattern 'categories/*.json'",
"lint:es": "eslint . --ignore-pattern 'icons/*.json' --ignore-pattern 'categories/*.json' --ignore-pattern 'groups/*.json'",
"lint:format": "prettier \"**/*.{js,mjs,ts,jsx,tsx,html,css,scss,json,yml,yaml}\" --check",
"lint:format-fix": "prettier \"**/*.{js,mjs,ts,jsx,tsx,html,css,scss,json,yml,yaml}\" --write",
"lint:icons": "prettier \"icons/*.svg\" --check",
@@ -44,8 +44,9 @@
"lint:format:ci": "node ./scripts/lintFormat.mts",
"lint:json:icons": "eslint 'icons/*.json'",
"lint:json:categories": "eslint 'categories/*.json'",
"lint:json": "eslint 'icons/*.json' 'categories/*.json'",
"lint:spelling": "cspell --no-progress 'icons/*.json' 'categories/*.json'",
"lint:json:groups": "eslint 'groups/*.json'",
"lint:json": "eslint 'icons/*.json' 'categories/*.json' 'groups/*.json'",
"lint:spelling": "cspell --no-progress 'icons/*.json' 'categories/*.json' 'groups/*.json'",
"lint:spelling:ci": "node ./scripts/lintSpelling.mts",
"lint:icons:all": "pnpm run lint:icons && pnpm run lint:json:icons",
"lint": "pnpm lint:es && pnpm lint:format && pnpm lint:json && pnpm lint:spelling && pnpm lint:icons",
+1 -1
View File
@@ -7,7 +7,7 @@ const config = {
singleAttributePerLine: true,
overrides: [
{
files: ['icons/*.json', 'categories/*.json', 'lab/*.json'],
files: ['icons/*.json', 'categories/*.json', 'groups/*.json', 'lab/*.json'],
options: {
printWidth: 0,
},
+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'));
+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;