2026-10-29 17:29:59 +01:00
import fs from 'fs/promises' ;
2022-05-30 18:38:47 +02:00
import path from 'path' ;
2024-07-07 21:32:32 +02:00
import {
readSvgDirectory ,
getCurrentDirPath ,
readAllMetadata ,
2026-10-29 17:29:58 +01:00
readMetadata ,
2026-10-29 17:29:58 +01:00
readGroups ,
2025-06-18 15:47:24 +02:00
} from '../tools/build-helpers/helpers.ts' ;
import { type IconMetadata } from '../tools/build-icons/types.ts' ;
2022-05-30 18:38:47 +02:00
2024-02-01 22:38:21 +09:00
const currentDir = getCurrentDirPath ( import . meta . url );
2022-11-07 11:34:40 +01:00
const ICONS_DIR = path . resolve ( currentDir , '../icons' );
2026-10-29 17:29:58 +01:00
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 ) } ` );
2026-10-29 17:29:58 +01:00
console . error ( 'This may be due to invalid extends or group references, or circular dependencies.' );
2026-10-29 17:29:58 +01:00
process . exit ( 1 );
}
2023-02-16 08:26:29 +01:00
const CATEGORIES_DIR = path . resolve ( currentDir , '../categories' );
2025-06-18 15:47:24 +02:00
const categories = await readAllMetadata ( CATEGORIES_DIR ) as Record < string , {
icon : string ;
name : string ;
}>;;
2022-05-30 18:38:47 +02:00
2024-02-01 22:38:21 +09:00
console . log ( 'Reading all icons' );
2022-05-30 18:38:47 +02:00
2025-02-19 11:19:53 +01:00
const svgFiles = await readSvgDirectory ( ICONS_DIR );
2024-02-01 22:38:21 +09:00
const iconNames = svgFiles . map (( icon ) => icon . split ( '.' )[ 0 ]);
2022-05-30 18:38:47 +02:00
let error = false ;
2024-02-01 22:38:21 +09:00
iconNames . forEach (( iconName ) => {
2023-02-16 08:26:29 +01:00
if ( typeof icons [ iconName ] === 'undefined' ) {
console . error ( `' ${ iconName } .svg' does not have a matching JSON file.` );
2022-05-30 18:38:47 +02:00
error = true ;
}
});
2024-02-01 22:38:21 +09:00
Object . keys ( icons ). forEach (( iconName ) => {
2023-02-16 08:26:29 +01:00
const icon = icons [ iconName ];
2022-05-30 18:38:47 +02:00
if ( iconNames . indexOf ( iconName ) === - 1 ) {
console . error ( `' ${ iconName } .svg' does not exist.` );
error = true ;
}
2025-06-18 15:47:24 +02:00
icon . categories ? . forEach (( categoryName ) => {
2023-02-16 08:26:29 +01:00
if ( typeof categories [ categoryName ] === 'undefined' ) {
2022-05-30 18:38:47 +02:00
console . error ( `Icon ' ${ iconName } ' refers to the non-existing category ' ${ categoryName } '.` );
error = true ;
}
});
});
2026-10-29 17:29:58 +01:00
const EXTENDS_PREFIX = '$extends:' ;
2026-10-29 17:29:58 +01:00
const GROUP_PREFIX = '$group:' ;
2026-10-29 17:29:58 +01:00
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' ,
};
2026-10-29 17:29:58 +01:00
const isMarker = ( entry : string ) =>
entry . startsWith ( EXTENDS_PREFIX ) || entry . startsWith ( GROUP_PREFIX );
const groups = await readGroups ();
const usedGroups = new Set < string >();
2026-10-29 17:29:58 +01:00
// `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 [] => {
2026-10-29 17:29:58 +01:00
if ( marker . startsWith ( GROUP_PREFIX )) {
const groupName = marker . slice ( GROUP_PREFIX . length );
usedGroups . add ( groupName );
return groups [ groupName ] ?? [];
}
2026-10-29 17:29:58 +01:00
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 ;
});
});
});
});
2026-10-29 17:29:59 +01:00
// An icon that lists every tag of a group as literals describes that group's
// meaning by hand, so it should reference the group instead.
2026-10-29 17:29:58 +01:00
Object . entries ( rawIcons ). forEach (([ iconName , rawMetadata ]) => {
const tags = rawMetadata . tags ?? [];
2026-10-29 17:29:59 +01:00
const literals = new Set ( tags . filter (( entry ) => ! isMarker ( entry )));
2026-10-29 17:29:58 +01:00
2026-10-29 17:29:59 +01:00
Object . entries ( groups )
. filter (([ groupName ]) => ! tags . includes ( ` ${ GROUP_PREFIX }${ groupName } ` ))
. filter (([, groupTags ]) => groupTags . every (( tag ) => literals . has ( tag )))
. forEach (([ groupName , groupTags ]) => {
console . error (
`Icon ' ${ iconName } ': tags ${ groupTags . map (( tag ) => `' ${ tag } '` ). join ( ', ' ) } are all in group ' ${ groupName } ' — use " ${ GROUP_PREFIX }${ groupName } " instead.` ,
);
error = true ;
2026-10-29 17:29:58 +01:00
});
});
2024-02-01 22:38:21 +09:00
Object . keys ( categories ). forEach (( categoryName ) => {
2023-02-16 08:26:29 +01:00
const category = categories [ categoryName ];
2025-06-18 15:47:24 +02:00
if ( ! category ? . icon ) {
2022-05-30 18:38:47 +02:00
console . error ( `Category ' ${ categoryName } ' does not use an icon ' ${ category . icon } '.` );
error = true ;
2023-02-16 08:26:29 +01:00
} else if ( typeof icons [ category . icon ] === 'undefined' ) {
2022-05-30 18:38:47 +02:00
console . error ( `Category ' ${ categoryName } ' uses the non-existing icon ' ${ category . icon } '.` );
error = true ;
}
});
2026-10-29 17:29:59 +01:00
// The schema lists every group as an enum so editors can autocomplete the
// markers; it has to match the files in `groups/`.
const iconSchema = JSON . parse (
await fs . readFile ( path . resolve ( currentDir , '../icon.schema.json' ), 'utf-8' ),
);
const schemaGroups = new Set < string >(
( iconSchema . $defs . groupMarker . enum as string []). map (( marker ) =>
marker . slice ( GROUP_PREFIX . length ),
),
);
Object . keys ( groups )
. filter (( groupName ) => ! schemaGroups . has ( groupName ))
. forEach (( groupName ) => {
console . error ( `Group ' ${ groupName } ' is missing in icon.schema.json.` );
error = true ;
});
[... schemaGroups ]
. filter (( groupName ) => typeof groups [ groupName ] === 'undefined' )
. forEach (( groupName ) => {
console . error (
`' ${ GROUP_PREFIX }${ groupName } ' in icon.schema.json refers to the non-existing group 'groups/ ${ groupName } .json'.` ,
);
error = true ;
});
2026-10-29 17:29:58 +01:00
Object . keys ( groups )
. filter (( groupName ) => ! usedGroups . has ( groupName ))
. forEach (( groupName ) => {
console . warn ( `Group ' ${ groupName } ' is not used by any icon.` );
});
2022-05-30 18:38:47 +02:00
if ( error ) {
2023-02-28 17:24:17 +01:00
console . error ( 'At least one error in icon JSONs prevents from committing changes.' );
process . exit ( 1 );
2022-05-30 18:38:47 +02:00
}