Files

195 lines
6.3 KiB
TypeScript
Raw Permalink Normal View History

import fs from 'fs/promises';
import path from 'path';
2024-07-07 21:32:32 +02:00
import {
readSvgDirectory,
getCurrentDirPath,
readAllMetadata,
readMetadata,
readGroups,
} from '../tools/build-helpers/helpers.ts';
import { type IconMetadata } from '../tools/build-icons/types.ts';
2024-02-01 22:38:21 +09:00
const currentDir = getCurrentDirPath(import.meta.url);
const ICONS_DIR = path.resolve(currentDir, '../icons');
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 or group references, or circular dependencies.');
process.exit(1);
}
2023-02-16 08:26:29 +01:00
const CATEGORIES_DIR = path.resolve(currentDir, '../categories');
const categories = await readAllMetadata(CATEGORIES_DIR) as Record<string, {
icon: string;
name: string;
}>;;
2024-02-01 22:38:21 +09:00
console.log('Reading all icons');
const svgFiles = await readSvgDirectory(ICONS_DIR);
2024-02-01 22:38:21 +09:00
const iconNames = svgFiles.map((icon) => icon.split('.')[0]);
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.`);
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];
if (iconNames.indexOf(iconName) === -1) {
console.error(`'${iconName}.svg' does not exist.`);
error = true;
}
icon.categories?.forEach((categoryName) => {
2023-02-16 08:26:29 +01:00
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<MarkedField, string> = {
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<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.
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[] => {
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<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;
});
});
});
});
// 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;
});
});
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];
if (!category?.icon) {
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') {
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<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;
});
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);
}