import fs from 'fs/promises'; import path from 'path'; import { readSvgDirectory, getCurrentDirPath, readAllMetadata, readMetadata, readGroups, } 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'); let icons: Record; 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 or group references, or circular dependencies.'); process.exit(1); } const CATEGORIES_DIR = path.resolve(currentDir, '../categories'); const categories = await readAllMetadata(CATEGORIES_DIR) as Record;; console.log('Reading all icons'); const svgFiles = await readSvgDirectory(ICONS_DIR); const iconNames = svgFiles.map((icon) => icon.split('.')[0]); let error = false; iconNames.forEach((iconName) => { if (typeof icons[iconName] === 'undefined') { console.error(`'${iconName}.svg' does not have a matching JSON file.`); error = true; } }); Object.keys(icons).forEach((iconName) => { const icon = icons[iconName]; if (iconNames.indexOf(iconName) === -1) { console.error(`'${iconName}.svg' does not exist.`); error = true; } icon.categories?.forEach((categoryName) => { if (typeof categories[categoryName] === 'undefined') { console.error(`Icon '${iconName}' refers to the non-existing category '${categoryName}'.`); error = true; } }); }); const EXTENDS_PREFIX = '$extends:'; const GROUP_PREFIX = '$group:'; const MARKED_FIELDS = ['tags', 'categories', 'contributors'] as const; type MarkedField = (typeof MARKED_FIELDS)[number]; const FIELD_LABELS: Record = { tags: 'tag', categories: 'category', contributors: 'contributor', }; 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. 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>, ]), ), ) as Record>>; /** 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) ?? []; }; 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(); 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; }); }); }); }); // 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. Object.entries(rawIcons).forEach(([iconName, rawMetadata]) => { const tags = rawMetadata.tags ?? []; const literals = new Set(tags.filter((entry) => !isMarker(entry))); 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; }); }); Object.keys(categories).forEach((categoryName) => { const category = categories[categoryName]; if (!category?.icon) { console.error(`Category '${categoryName}' does not use an icon '${category.icon}'.`); error = true; } else if (typeof icons[category.icon] === 'undefined') { console.error(`Category '${categoryName}' uses the non-existing icon '${category.icon}'.`); error = true; } }); // 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( (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; }); 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); }