From 831740411f5c9fc8a0160b353f85fbb643cede8a Mon Sep 17 00:00:00 2001 From: Eric Fennis Date: Thu, 29 Oct 2026 17:29:58 +0100 Subject: [PATCH] feat(metadata|docs): reusable tag groups with `$group:` (#5037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(metadata): reusable tag groups with `$group:` A `$group:` marker in `tags` is replaced in place with the tags of `groups/.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 * 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:` marker in `tags` is replaced in place with the tags of `groups/.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 * 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:` marker in `tags` is replaced in place with the tags of `groups/.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 * 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:` marker in `tags` is replaced in place with the tags of `groups/.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 * 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 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/instructions/groups.instructions.md | 29 +++++++ .github/instructions/metadata.instructions.md | 28 ++++++- .github/labeler.yml | 1 + .github/workflows/linting-icons.yml | 1 + .../icons/metadata-conventions/index.md | 26 +++--- eslint.config.js | 8 +- group.schema.json | 40 +++++++++ groups/minus.json | 8 ++ groups/plus.json | 9 ++ groups/x.json | 9 ++ icon.schema.json | 2 +- icons/alarm-clock-minus.json | 12 ++- icons/file-minus.json | 14 ++-- icons/file-plus.json | 14 ++-- icons/file-x.json | 15 ++-- lint-staged.config.mjs | 5 ++ package.json | 7 +- prettier.config.mjs | 2 +- scripts/checkIconsAndCategories.mts | 45 +++++++++- scripts/lintSpelling.mts | 2 +- tools/build-helpers/helpers.ts | 1 + tools/build-helpers/src/readAllMetadata.ts | 7 +- tools/build-helpers/src/readGroups.ts | 53 ++++++++++++ .../src/resolveMetadataExtends.ts | 83 +++++++++++++++++-- tools/build-icons/utils/getIconMetaData.ts | 8 +- 25 files changed, 375 insertions(+), 54 deletions(-) create mode 100644 .github/instructions/groups.instructions.md create mode 100644 group.schema.json create mode 100644 groups/minus.json create mode 100644 groups/plus.json create mode 100644 groups/x.json create mode 100644 tools/build-helpers/src/readGroups.ts diff --git a/.github/instructions/groups.instructions.md b/.github/instructions/groups.instructions.md new file mode 100644 index 000000000..c63c314d8 --- /dev/null +++ b/.github/instructions/groups.instructions.md @@ -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:"` 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. diff --git a/.github/instructions/metadata.instructions.md b/.github/instructions/metadata.instructions.md index 99f18c5ff..f8dd93713 100644 --- a/.github/instructions/metadata.instructions.md +++ b/.github/instructions/metadata.instructions.md @@ -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/.json` and `icons/.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:"` marker inside `tags` is replaced, in place, with the tags of `groups/.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. diff --git a/.github/labeler.yml b/.github/labeler.yml index 6029f5e00..b04b84b1e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -23,6 +23,7 @@ - any-glob-to-any-file: - 'icons/*.json' - categories/* + - groups/* # For changes or added icons 🎨 icon: diff --git a/.github/workflows/linting-icons.yml b/.github/workflows/linting-icons.yml index 0e5e9b5f3..c06721e4d 100644 --- a/.github/workflows/linting-icons.yml +++ b/.github/workflows/linting-icons.yml @@ -8,6 +8,7 @@ on: paths: - 'icons/*' - 'lab/*' + - 'groups/*' permissions: {} diff --git a/docs/contribute/icons/metadata-conventions/index.md b/docs/contribute/icons/metadata-conventions/index.md index 464ea32ea..b370e5ca2 100644 --- a/docs/contribute/icons/metadata-conventions/index.md +++ b/docs/contribute/icons/metadata-conventions/index.md @@ -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:` 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:` means **is a**. It pulls in the resolved `tags`, `categories` or `contributors` of the icon the variant is built on. + +`$group:` means **does this**. It pulls in the tags of `groups/.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. diff --git a/eslint.config.js b/eslint.config.js index 49aa4a5c7..f33c8ce5a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' }], + }, + }, ]); diff --git a/group.schema.json b/group.schema.json new file mode 100644 index 000000000..af9dafbf1 --- /dev/null +++ b/group.schema.json @@ -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." +} diff --git a/groups/minus.json b/groups/minus.json new file mode 100644 index 000000000..f0247e617 --- /dev/null +++ b/groups/minus.json @@ -0,0 +1,8 @@ +{ + "$schema": "../group.schema.json", + "description": "Action: removing or deleting something", + "tags": [ + "remove", + "delete" + ] +} diff --git a/groups/plus.json b/groups/plus.json new file mode 100644 index 000000000..5e16e83e4 --- /dev/null +++ b/groups/plus.json @@ -0,0 +1,9 @@ +{ + "$schema": "../group.schema.json", + "description": "Action: adding or creating something", + "tags": [ + "add", + "new", + "create" + ] +} diff --git a/groups/x.json b/groups/x.json new file mode 100644 index 000000000..7327455f2 --- /dev/null +++ b/groups/x.json @@ -0,0 +1,9 @@ +{ + "$schema": "../group.schema.json", + "description": "Action: removing, deleting or cancelling something", + "tags": [ + "remove", + "delete", + "cancel" + ] +} diff --git a/icon.schema.json b/icon.schema.json index 01dbca816..ffb98164d 100644 --- a/icon.schema.json +++ b/icon.schema.json @@ -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, diff --git a/icons/alarm-clock-minus.json b/icons/alarm-clock-minus.json index 28a0c8541..982a0b290 100644 --- a/icons/alarm-clock-minus.json +++ b/icons/alarm-clock-minus.json @@ -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": [ { diff --git a/icons/file-minus.json b/icons/file-minus.json index ad3e62aa2..d4f50ed37 100644 --- a/icons/file-minus.json +++ b/icons/file-minus.json @@ -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" ] } diff --git a/icons/file-plus.json b/icons/file-plus.json index 771969076..08b758ecc 100644 --- a/icons/file-plus.json +++ b/icons/file-plus.json @@ -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" ] } diff --git a/icons/file-x.json b/icons/file-x.json index 4ebb3aaf6..23fa8bdd0 100644 --- a/icons/file-x.json +++ b/icons/file-x.json @@ -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" ] } diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index f3fc29277..37c788c0a 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -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; diff --git a/package.json b/package.json index 17563caca..f12e103fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prettier.config.mjs b/prettier.config.mjs index 08773552f..d5903f71a 100644 --- a/prettier.config.mjs +++ b/prettier.config.mjs @@ -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, }, diff --git a/scripts/checkIconsAndCategories.mts b/scripts/checkIconsAndCategories.mts index b77568aaa..4fd427c71 100644 --- a/scripts/checkIconsAndCategories.mts +++ b/scripts/checkIconsAndCategories.mts @@ -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; } 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 = { 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(); // `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); diff --git a/scripts/lintSpelling.mts b/scripts/lintSpelling.mts index 512522e9b..4a899a578 100644 --- a/scripts/lintSpelling.mts +++ b/scripts/lintSpelling.mts @@ -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')); diff --git a/tools/build-helpers/helpers.ts b/tools/build-helpers/helpers.ts index 60f354649..90c6b8dc2 100644 --- a/tools/build-helpers/helpers.ts +++ b/tools/build-helpers/helpers.ts @@ -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'; diff --git a/tools/build-helpers/src/readAllMetadata.ts b/tools/build-helpers/src/readAllMetadata.ts index 270b8ea91..30424d1ee 100644 --- a/tools/build-helpers/src/readAllMetadata.ts +++ b/tools/build-helpers/src/readAllMetadata.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.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> => { + 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)); +}; diff --git a/tools/build-helpers/src/resolveMetadataExtends.ts b/tools/build-helpers/src/resolveMetadataExtends.ts index 7a5258f70..69be5d4ac 100644 --- a/tools/build-helpers/src/resolveMetadataExtends.ts +++ b/tools/build-helpers/src/resolveMetadataExtends.ts @@ -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; + interface ResolveOptions { validateReferences?: boolean; + groups?: Groups; } /** @@ -18,19 +32,22 @@ interface ResolveOptions { * Recursively resolves extends chains and merges inherited properties * Inline `$extends:` markers inside the tags/categories/contributors * arrays are expanded in place with the referenced icon's resolved values. + * Inline `$group:` markers inside the tags array are expanded in + * place with the tags of the matching `groups/.json`. */ export const resolveMetadataExtends = async ( allMetadata: Record, options: ResolveOptions = {}, ): Promise> => { - const { validateReferences = true } = options; + const { validateReferences = true, groups = {} } = options; const visited = new Set(); const resolved: Record = {}; - // 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, visited: Set, recursionStack: Set, + groups: Groups = {}, ): IconMetadata => { // Check for circular dependency if (recursionStack.has(iconName)) { @@ -71,8 +89,9 @@ export const resolveIconExtends = ( recursionStack.add(iconName); - // Expand inline `$extends:` markers within a single field, keeping - // literal values in place and de-duplicating the result (first occurrence wins). + // Expand inline `$extends:` and `$group:` 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)' + }`, + ); + } +} diff --git a/tools/build-icons/utils/getIconMetaData.ts b/tools/build-icons/utils/getIconMetaData.ts index ca7430a73..fc0651396 100644 --- a/tools/build-icons/utils/getIconMetaData.ts +++ b/tools/build-icons/utils/getIconMetaData.ts @@ -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