mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
feat(metadata|docs|site): reuse metadata from a base icon with $extends: (#4360)
* feat(metadata): reuse metadata from a base icon with `$extends:` Icon metadata can reference a base icon with an inline `$extends:<icon-name>` marker in `tags`, `categories` or `contributors`. The marker is replaced in place with the base icon's resolved values at build time. - Resolve markers in `readAllMetadata` and `getIconMetaData`, failing on unknown icons, self references and circular chains - Accept markers in `icon.schema.json` - `pnpm checkIcons` fails on duplicate markers and on literals a marker already provides, and runs in CI - Docs read the resolved metadata index instead of the raw icon JSON - Use `$extends:alarm-clock` in `alarm-clock-minus` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(metadata): reuse metadata from a base icon with `$extends:` Icon metadata can reference a base icon with an inline `$extends:<icon-name>` marker in `tags`, `categories` or `contributors`. The marker is replaced in place with the base icon's resolved values at build time. - Resolve markers in `readAllMetadata` and `getIconMetaData`, failing on unknown icons, self references and circular chains - Accept markers in `icon.schema.json` - `pnpm checkIcons` fails on duplicate markers and on literals a marker already provides, and runs in CI - Docs read the resolved metadata index instead of the raw icon JSON - Use `$extends:alarm-clock` in `alarm-clock-minus` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix comments * Format code * Update tools/build-helpers/src/resolveMetadataExtends.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix resolving icon metadata * feat(metadata): reuse metadata from a base icon with `$extends:` Icon metadata can reference a base icon with an inline `$extends:<icon-name>` marker in `tags`, `categories` or `contributors`. The marker is replaced in place with the base icon's resolved values at build time. - Resolve markers in `readAllMetadata` and `getIconMetaData`, failing on unknown icons, self references and circular chains - Accept markers in `icon.schema.json` - `pnpm checkIcons` fails on duplicate markers and on literals a marker already provides, and runs in CI - Docs read the resolved metadata index instead of the raw icon JSON - Use `$extends:alarm-clock` in `alarm-clock-minus` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix comments * Format code * Update tools/build-helpers/src/resolveMetadataExtends.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Fix resolving icon metadata * Fix comments * Format code * Update tools/build-helpers/src/resolveMetadataExtends.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
parent
e2c3360976
commit
7bf67d3b1c
@@ -19,6 +19,33 @@ Provide tag suggestions based on the name of the icon and the use cases provided
|
||||
|
||||
The `categories` property is an array of strings that specify the categories to which the icon belongs, such as "devices", "interface", "media", etc. See the `categories` property in the `icon.schema.json` for more details on the allowed values. The categories should be chosen based on the use cases provided in the PR description and the existing categories in the repository. The categories should be relevant to the icon and should help users find the icon when searching for specific types of icons. The name of icon should not be included in the categories, as it is already specified. Suggest categories based on the name of the icon and the use cases provided in the PR description. Use the existing categories in the repository as a reference for consistency and to avoid duplicates. Categories should always be in lowercase and should not contain spaces.
|
||||
|
||||
## Extending metadata from other icons
|
||||
|
||||
Some icons are variants of another icon (for example `alarm-clock-minus` is an `alarm-clock`). Instead of repeating the same values, an icon can inherit `tags`, `categories`, or `contributors` from its **base icon** using an inline `"$extends:<icon-name>"` marker placed directly inside the relevant array. At build time each marker is replaced, in place, with the referenced icon's fully resolved values for that same field.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tags": ["$extends:alarm-clock", "remove"],
|
||||
"categories": ["$extends:alarm-clock"],
|
||||
"contributors": ["$extends:alarm-clock", "some-user"]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## 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"`.
|
||||
|
||||
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.
|
||||
|
||||
## Use Cases
|
||||
|
||||
The `use-cases` property is an array of strings that describe the specific scenarios in which the icon would be used. Each use case should be concise and clearly convey the context in which the icon is applicable. Use the existing use cases in the repository as a reference for consistency and to avoid duplicates.
|
||||
|
||||
@@ -42,6 +42,25 @@ jobs:
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
check-icons:
|
||||
name: Check Icons & Categories
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: 'package.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check icons and categories
|
||||
run: pnpm checkIcons
|
||||
|
||||
check-lab-icons:
|
||||
name: Check Lab Icons
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"name": "layout",
|
||||
"title": "Layout"
|
||||
},
|
||||
{
|
||||
"name": "letters-digits",
|
||||
"title": "Letters & digits"
|
||||
},
|
||||
{
|
||||
"name": "mail",
|
||||
"title": "Mail"
|
||||
@@ -97,7 +101,7 @@
|
||||
},
|
||||
{
|
||||
"name": "navigation",
|
||||
"title": "Navigation & Places"
|
||||
"title": "Navigation & places"
|
||||
},
|
||||
{
|
||||
"name": "notifications",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IconNodeWithKeys } from '../theme/types';
|
||||
import iconNodes from '../data/iconNodes';
|
||||
import releaseMeta from '../data/releaseMetaData.json';
|
||||
|
||||
const DATE_OF_FORK = '2020-06-08T16:39:52+0100';
|
||||
|
||||
const directory = path.join(process.cwd(), '../icons');
|
||||
|
||||
export interface GetDataOptions {
|
||||
withChildKeys?: boolean;
|
||||
}
|
||||
|
||||
export async function getData(name: string) {
|
||||
const jsonPath = path.join(directory, `${name}.json`);
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf8');
|
||||
const { tags, categories, contributors } = JSON.parse(jsonContent);
|
||||
|
||||
const iconNode = iconNodes[name];
|
||||
|
||||
const releaseData = releaseMeta?.[name] ?? {
|
||||
fromFork: false,
|
||||
awaitingRelease: false,
|
||||
createdRelease: {
|
||||
version: '0.0.0',
|
||||
date: DATE_OF_FORK,
|
||||
},
|
||||
changedRelease: {
|
||||
version: '0.0.0',
|
||||
date: DATE_OF_FORK,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
tags,
|
||||
categories,
|
||||
iconNode,
|
||||
contributors,
|
||||
...releaseData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllData(): Promise<{ name: string; iconNode: IconNodeWithKeys }[]> {
|
||||
const names = Object.keys(iconNodes);
|
||||
|
||||
return Promise.all(names.map((name) => getData(name)));
|
||||
}
|
||||
@@ -135,6 +135,32 @@ 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:`
|
||||
|
||||
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.
|
||||
|
||||
`alarm-clock-minus` is an alarm clock:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": ["$extends:alarm-clock", "remove"],
|
||||
"categories": ["$extends:alarm-clock"]
|
||||
}
|
||||
```
|
||||
|
||||
which resolves to:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": ["morning", "remove"],
|
||||
"categories": ["devices", "notifications", "time"]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Put the marker first, before the icon's own values, and never repeat a value the marker already provides — `pnpm checkIcons` fails on that.
|
||||
|
||||
## Aliases
|
||||
|
||||
Some icons can include an optional `aliases` field for alternate names.
|
||||
@@ -149,6 +175,12 @@ Before submitting metadata changes, validate the icon JSON files:
|
||||
pnpm run lint:json:icons
|
||||
```
|
||||
|
||||
`$extends:` markers are checked separately:
|
||||
|
||||
```sh
|
||||
pnpm checkIcons
|
||||
```
|
||||
|
||||
You can also format changed files with Prettier:
|
||||
|
||||
```sh
|
||||
@@ -166,4 +198,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.
|
||||
- `pnpm run lint:json:icons` passes.
|
||||
- Reused metadata uses `$extends:` for the base icon, with no repeated values.
|
||||
- `pnpm run lint:json:icons` and `pnpm checkIcons` pass.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { readSvgDirectory, toCamelCase } from '@lucide/helpers';
|
||||
import { getIconMetaData } from '@lucide/build-icons';
|
||||
import { toCamelCase } from '@lucide/helpers';
|
||||
|
||||
const currentDir = process.cwd();
|
||||
const ICONS_DIR = path.resolve(currentDir, '../icons');
|
||||
const icons = await readSvgDirectory(ICONS_DIR, '.json');
|
||||
const iconMetaData = await getIconMetaData(ICONS_DIR);
|
||||
|
||||
const iconDetailsDirectory = path.resolve(currentDir, '.vitepress/data', 'iconDetails');
|
||||
|
||||
@@ -18,18 +19,27 @@ if (!fs.existsSync(iconDetailsDirectory)) {
|
||||
|
||||
const indexFile = path.resolve(iconDetailsDirectory, `index.ts`);
|
||||
|
||||
const writeIconFiles = icons.map(async (iconFileName) => {
|
||||
const iconName = path.basename(iconFileName, '.json');
|
||||
const writeIconFiles = Object.entries(iconMetaData).map(async ([iconName, metaData]) => {
|
||||
const location = path.resolve(iconDetailsDirectory, `${iconName}.ts`);
|
||||
const {
|
||||
tags,
|
||||
categories,
|
||||
contributors,
|
||||
aliases,
|
||||
deprecated,
|
||||
deprecationReason,
|
||||
toBeRemovedInVersion,
|
||||
} = metaData;
|
||||
|
||||
const contents = `\
|
||||
import iconNode from '../iconNodes/${iconName}.node.json' with { type: 'json' };
|
||||
import metaData from '../../../../icons/${iconName}.json' with { type: 'json' };
|
||||
import releaseData from '../releaseMetadata/${iconName}.json' with { type: 'json' };
|
||||
import popularity from '../iconPopularity/${iconName}.json' with { type: 'json' };
|
||||
import { IconMetaData, IconNode } from '~/.vitepress/theme/types';
|
||||
|
||||
const { tags, categories, contributors, aliases, deprecated, deprecationReason, toBeRemovedInVersion }: IconMetaData = metaData;
|
||||
const metaData: IconMetaData = ${JSON.stringify({ tags, categories, contributors, aliases, deprecated, deprecationReason, toBeRemovedInVersion })};
|
||||
|
||||
const { tags, categories, contributors, aliases, deprecated, deprecationReason, toBeRemovedInVersion } = metaData;
|
||||
|
||||
const iconDetails = {
|
||||
name: '${iconName}',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { readSvgDirectory, toCamelCase } from '@lucide/helpers';
|
||||
import { readAllMetadata } from '@lucide/helpers';
|
||||
|
||||
const currentDir = process.cwd();
|
||||
const ICONS_DIR = path.resolve(currentDir, '../icons');
|
||||
const iconJsonFiles = await readSvgDirectory(ICONS_DIR, '.json');
|
||||
|
||||
const location = path.resolve(currentDir, '.vitepress/data', 'iconMetaData.ts');
|
||||
|
||||
@@ -12,29 +11,15 @@ if (fs.existsSync(location)) {
|
||||
fs.unlinkSync(location);
|
||||
}
|
||||
|
||||
const iconMetaIndexFileImports = [];
|
||||
const iconMetaIndexFileExports = [];
|
||||
|
||||
iconJsonFiles.forEach((iconJsonFile) => {
|
||||
const iconName = path.basename(iconJsonFile, '.json');
|
||||
|
||||
iconMetaIndexFileImports.push(
|
||||
`import ${toCamelCase(iconName)}Metadata from '../../../icons/${iconName}.json';`,
|
||||
);
|
||||
iconMetaIndexFileExports.push(` '${iconName}': ${toCamelCase(iconName)}Metadata,`);
|
||||
});
|
||||
// The docs read this index raw and never re-resolve, so resolve the
|
||||
// `$extends:<icon-name>` markers here and bake the inherited
|
||||
// tags/categories/contributors into the generated file.
|
||||
const iconMetaData = await readAllMetadata(ICONS_DIR);
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
location,
|
||||
`\
|
||||
${iconMetaIndexFileImports.join('\n')}
|
||||
|
||||
|
||||
export default {
|
||||
${iconMetaIndexFileExports.join('\n')}
|
||||
}
|
||||
`,
|
||||
`export default ${JSON.stringify(iconMetaData, null, 2)};\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
|
||||
+67
-47
@@ -50,51 +50,59 @@
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accessibility",
|
||||
"account",
|
||||
"animals",
|
||||
"arrows",
|
||||
"buildings",
|
||||
"charts",
|
||||
"communication",
|
||||
"connectivity",
|
||||
"cursors",
|
||||
"design",
|
||||
"development",
|
||||
"devices",
|
||||
"emoji",
|
||||
"files",
|
||||
"finance",
|
||||
"food-beverage",
|
||||
"gaming",
|
||||
"home",
|
||||
"layout",
|
||||
"letters-digits",
|
||||
"mail",
|
||||
"math",
|
||||
"medical",
|
||||
"multimedia",
|
||||
"nature",
|
||||
"navigation",
|
||||
"notifications",
|
||||
"people",
|
||||
"photography",
|
||||
"science",
|
||||
"seasons",
|
||||
"security",
|
||||
"shapes",
|
||||
"shopping",
|
||||
"social",
|
||||
"sports",
|
||||
"sustainability",
|
||||
"text",
|
||||
"time",
|
||||
"tools",
|
||||
"transportation",
|
||||
"travel",
|
||||
"weather"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accessibility",
|
||||
"account",
|
||||
"animals",
|
||||
"arrows",
|
||||
"buildings",
|
||||
"charts",
|
||||
"communication",
|
||||
"connectivity",
|
||||
"cursors",
|
||||
"design",
|
||||
"development",
|
||||
"devices",
|
||||
"emoji",
|
||||
"files",
|
||||
"finance",
|
||||
"food-beverage",
|
||||
"gaming",
|
||||
"home",
|
||||
"layout",
|
||||
"letters-digits",
|
||||
"mail",
|
||||
"math",
|
||||
"medical",
|
||||
"multimedia",
|
||||
"nature",
|
||||
"navigation",
|
||||
"notifications",
|
||||
"people",
|
||||
"photography",
|
||||
"science",
|
||||
"seasons",
|
||||
"security",
|
||||
"shapes",
|
||||
"shopping",
|
||||
"social",
|
||||
"sports",
|
||||
"sustainability",
|
||||
"text",
|
||||
"time",
|
||||
"tools",
|
||||
"transportation",
|
||||
"travel",
|
||||
"weather"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^\\$extends:[a-z0-9]+(-[a-z0-9]+)*$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
@@ -102,7 +110,13 @@
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"if": {
|
||||
"pattern": "^\\$[a-zA-Z]"
|
||||
},
|
||||
"then": {
|
||||
"pattern": "^\\$extends:[a-z0-9]+(-[a-z0-9]+)*$"
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
@@ -110,7 +124,13 @@
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"if": {
|
||||
"pattern": "^\\$[a-zA-Z]"
|
||||
},
|
||||
"then": {
|
||||
"pattern": "^\\$extends:[a-z0-9]+(-[a-z0-9]+)*$"
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
|
||||
@@ -8,12 +8,11 @@
|
||||
],
|
||||
"use-cases": [],
|
||||
"tags": [
|
||||
"$extends:alarm-clock",
|
||||
"remove"
|
||||
],
|
||||
"categories": [
|
||||
"devices",
|
||||
"notifications",
|
||||
"time"
|
||||
"$extends:alarm-clock"
|
||||
],
|
||||
"aliases": [
|
||||
{
|
||||
|
||||
@@ -3,12 +3,22 @@ import {
|
||||
readSvgDirectory,
|
||||
getCurrentDirPath,
|
||||
readAllMetadata,
|
||||
readMetadata,
|
||||
} from '../tools/build-helpers/helpers.ts';
|
||||
import { type IconMetadata } from '../tools/build-icons/types.ts';
|
||||
|
||||
const currentDir = getCurrentDirPath(import.meta.url);
|
||||
const ICONS_DIR = path.resolve(currentDir, '../icons');
|
||||
const icons = await readAllMetadata(ICONS_DIR) as Record<string, IconMetadata>;
|
||||
let icons: Record<string, IconMetadata>;
|
||||
|
||||
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.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CATEGORIES_DIR = path.resolve(currentDir, '../categories');
|
||||
const categories = await readAllMetadata(CATEGORIES_DIR) as Record<string, {
|
||||
icon: string;
|
||||
@@ -43,6 +53,66 @@ Object.keys(icons).forEach((iconName) => {
|
||||
});
|
||||
});
|
||||
|
||||
const EXTENDS_PREFIX = '$extends:';
|
||||
|
||||
const MARKED_FIELDS = ['tags', 'categories', 'contributors'] as const;
|
||||
type MarkedField = (typeof MARKED_FIELDS)[number];
|
||||
|
||||
const FIELD_LABELS: Record<MarkedField, string> = {
|
||||
tags: 'tag',
|
||||
categories: 'category',
|
||||
contributors: 'contributor',
|
||||
};
|
||||
|
||||
const isMarker = (entry: string) => entry.startsWith(EXTENDS_PREFIX);
|
||||
|
||||
// `readAllMetadata` expands the markers, so the duplicate checks below read the
|
||||
// JSON as it is written on disk to see which entries are literals.
|
||||
const iconJsonFiles = await readSvgDirectory(ICONS_DIR, '.json');
|
||||
const rawIcons = Object.fromEntries(
|
||||
await Promise.all(
|
||||
iconJsonFiles.map(async (file) => [
|
||||
path.basename(file, '.json'),
|
||||
(await readMetadata(file, ICONS_DIR)) as Partial<Record<MarkedField, string[]>>,
|
||||
]),
|
||||
),
|
||||
) as Record<string, Partial<Record<MarkedField, string[]>>>;
|
||||
|
||||
/** The values a single marker contributes to `field`. */
|
||||
const resolveMarker = (marker: string, field: MarkedField): string[] => {
|
||||
const parentName = marker.slice(EXTENDS_PREFIX.length);
|
||||
|
||||
return (icons[parentName]?.[field] as string[] | undefined) ?? [];
|
||||
};
|
||||
|
||||
Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => {
|
||||
MARKED_FIELDS.forEach((field) => {
|
||||
const entries = rawMetadata[field] ?? [];
|
||||
const literals = new Set(entries.filter((entry) => !isMarker(entry)));
|
||||
const seenMarkers = new Set<string>();
|
||||
|
||||
entries.filter(isMarker).forEach((marker) => {
|
||||
if (seenMarkers.has(marker)) {
|
||||
console.error(
|
||||
`Icon '${iconName}': '${marker}' is listed twice in '${field}' — remove the duplicate marker.`,
|
||||
);
|
||||
error = true;
|
||||
return;
|
||||
}
|
||||
seenMarkers.add(marker);
|
||||
|
||||
resolveMarker(marker, field)
|
||||
.filter((value) => literals.has(value))
|
||||
.forEach((value) => {
|
||||
console.error(
|
||||
`Icon '${iconName}': ${FIELD_LABELS[field]} '${value}' is already provided by ${marker} — remove the literal.`,
|
||||
);
|
||||
error = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(categories).forEach((categoryName) => {
|
||||
const category = categories[categoryName];
|
||||
if (!category?.icon) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { readMetadata } from './readMetadata.ts';
|
||||
import { resolveMetadataExtends } from './resolveMetadataExtends.ts';
|
||||
import { type IconMetadata } from '../../build-icons/types.ts';
|
||||
|
||||
/**
|
||||
@@ -22,5 +23,14 @@ export const readAllMetadata = async (directory: string): Promise<Record<string,
|
||||
throw new Error(`No metadata files found in directory: ${directory}`);
|
||||
}
|
||||
|
||||
return Object.fromEntries(metadata);
|
||||
const metadataMap = Object.fromEntries(metadata);
|
||||
|
||||
// Resolve extends references
|
||||
try {
|
||||
return await resolveMetadataExtends(metadataMap);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve metadata extends: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { type IconMetadata } from '../../build-icons/types.ts';
|
||||
import { mergeArrays } from './mergeArrays.ts';
|
||||
|
||||
const EXTENDS_PREFIX = '$extends:';
|
||||
|
||||
const EXTENDABLE_FIELDS = ['tags', 'categories', 'contributors'] as const;
|
||||
type ExtendableField = (typeof EXTENDABLE_FIELDS)[number];
|
||||
|
||||
const isExtendsMarker = (value: string): boolean => value.startsWith(EXTENDS_PREFIX);
|
||||
const getExtendsTarget = (value: string): string => value.slice(EXTENDS_PREFIX.length);
|
||||
|
||||
interface ResolveOptions {
|
||||
validateReferences?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves metadata extends for all icons
|
||||
* 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.
|
||||
*/
|
||||
export const resolveMetadataExtends = async (
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
options: ResolveOptions = {},
|
||||
): Promise<Record<string, IconMetadata>> => {
|
||||
const { validateReferences = true } = options;
|
||||
const visited = new Set<string>();
|
||||
const resolved: Record<string, IconMetadata> = {};
|
||||
|
||||
// Validate all extends references first if requested
|
||||
if (validateReferences) {
|
||||
for (const [iconName, metadata] of Object.entries(allMetadata)) {
|
||||
validateExtendsReferences(iconName, metadata, allMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each icon. Icons already resolved as a parent of an earlier icon
|
||||
// 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());
|
||||
}
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves extends for a single icon
|
||||
* Uses recursion stack to detect circular dependencies
|
||||
*/
|
||||
export const resolveIconExtends = (
|
||||
iconName: string,
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
visited: Set<string>,
|
||||
recursionStack: Set<string>,
|
||||
): IconMetadata => {
|
||||
// Check for circular dependency
|
||||
if (recursionStack.has(iconName)) {
|
||||
throw new Error(`Circular dependency detected: Icon '${iconName}' extends itself in a cycle`);
|
||||
}
|
||||
|
||||
// Return cached result if already resolved
|
||||
if (visited.has(iconName)) {
|
||||
return allMetadata[iconName] as IconMetadata;
|
||||
}
|
||||
|
||||
const metadata = allMetadata[iconName];
|
||||
if (!metadata) {
|
||||
throw new Error(`Icon '${iconName}' not found`);
|
||||
}
|
||||
|
||||
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).
|
||||
const resolveField = (field: ExtendableField): string[] => {
|
||||
const source = (metadata[field] as string[] | undefined) ?? [];
|
||||
const expanded: string[] = [];
|
||||
|
||||
for (const entry of source) {
|
||||
if (isExtendsMarker(entry)) {
|
||||
const parentName = getExtendsTarget(entry);
|
||||
validateParentExists(iconName, parentName, allMetadata);
|
||||
validateNotSelfReference(iconName, parentName);
|
||||
|
||||
const parent = resolveIconExtends(parentName, allMetadata, visited, recursionStack);
|
||||
expanded.push(...((parent[field] as string[] | undefined) ?? []));
|
||||
} else {
|
||||
expanded.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeArrays(expanded, []);
|
||||
};
|
||||
|
||||
const resolvedMetadata = {
|
||||
...metadata,
|
||||
tags: resolveField('tags'),
|
||||
categories: resolveField('categories'),
|
||||
contributors: resolveField('contributors'),
|
||||
} as IconMetadata;
|
||||
|
||||
recursionStack.delete(iconName);
|
||||
visited.add(iconName);
|
||||
|
||||
// Update the metadata in place for caching
|
||||
Object.assign(allMetadata[iconName], resolvedMetadata);
|
||||
|
||||
return resolvedMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all inline extends markers point to existing icons
|
||||
*/
|
||||
export const validateExtendsReferences = (
|
||||
iconName: string,
|
||||
metadata: IconMetadata,
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
): void => {
|
||||
for (const field of EXTENDABLE_FIELDS) {
|
||||
for (const entry of (metadata[field] as string[] | undefined) ?? []) {
|
||||
if (!isExtendsMarker(entry)) continue;
|
||||
|
||||
const parentName = getExtendsTarget(entry);
|
||||
if (!allMetadata[parentName]) {
|
||||
throw new Error(`Icon '${iconName}' extends non-existent icon '${parentName}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a parent exists in metadata
|
||||
*/
|
||||
function validateParentExists(
|
||||
iconName: string,
|
||||
parentName: string,
|
||||
allMetadata: Record<string, IconMetadata>,
|
||||
): void {
|
||||
if (!allMetadata[parentName]) {
|
||||
throw new Error(`Icon '${iconName}' extends non-existent icon '${parentName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an icon doesn't extend from itself
|
||||
*/
|
||||
function validateNotSelfReference(iconName: string, parentName: string): void {
|
||||
if (iconName === parentName) {
|
||||
throw new Error(`Icon '${iconName}' cannot extend from itself`);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,12 @@ export type IconDeprecationReason = 'icon.design' | 'icon.use-case' | '';
|
||||
|
||||
export type IconMetadataBase = {
|
||||
categories: string[];
|
||||
contributors?: string[];
|
||||
aliases?: AliasDeprecation[];
|
||||
tags: string[];
|
||||
deprecationReason?: IconDeprecationReason;
|
||||
deprecated?: boolean;
|
||||
toBeRemovedInVersion?: string;
|
||||
};
|
||||
|
||||
export type IconMetadataWithDeprecation = IconMetadataBase & {
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { readSvgDirectory } from '@lucide/helpers';
|
||||
import { type IconMetadata } from '../types.ts';
|
||||
import { resolveMetadataExtends } from '../../build-helpers/src/resolveMetadataExtends.ts';
|
||||
|
||||
async function getIconMetaData(iconDirectory: string): Promise<Record<string, IconMetadata>> {
|
||||
const iconJsons = await readSvgDirectory(iconDirectory, '.json');
|
||||
@@ -15,7 +16,10 @@ async function getIconMetaData(iconDirectory: string): Promise<Record<string, Ic
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.fromEntries(aliasesEntries);
|
||||
const metadataMap = Object.fromEntries(aliasesEntries);
|
||||
|
||||
// Resolve extends references
|
||||
return await resolveMetadataExtends(metadataMap);
|
||||
}
|
||||
|
||||
export default getIconMetaData;
|
||||
|
||||
Reference in New Issue
Block a user