import { globSync } from 'glob' import fs from 'fs' import { basename } from 'path' import { HOME_DIR, ICONS_SRC_DIR, iconTemplate, parseMatter, types, getArgvs, categories } from './helpers.mjs' import { join } from 'path' import { execSync } from 'child_process' let error = false const outlineIconsNames = globSync(join(ICONS_SRC_DIR, 'outline/*.svg')).map(i => basename(i, '.svg')).sort(), filledIconsNames = globSync(join(ICONS_SRC_DIR, 'filled/*.svg')).map(i => basename(i, '.svg')).sort(), argvs = getArgvs(), aliases = JSON.parse(fs.readFileSync(join(HOME_DIR, 'aliases.json'), 'utf-8')); let unicodes = [] // Validate that only .svg files exist in icons/filled and icons/outline directories types.forEach(type => { const dirPath = join(ICONS_SRC_DIR, type) const files = fs.readdirSync(dirPath) files.forEach(file => { // Ignore .DS_Store (macOS system file) if (file === '.DS_Store') { return } if (!file.endsWith('.svg')) { console.log(`⛔️ Directory \`icons/${type}\` contains non-SVG file: \`${file}\``) error = true } }) }) const duplicateExists = (arr) => { return new Set(arr).size !== arr.length } const getIconName = (icon) => { //return last two parts of the path return icon.split('/').slice(-2).join('/') } function getAddedIconsFromMain() { try { // Use BASE_SHA or BASE_REF from environment, fallback to origin/main const baseRef = process.env.BASE_SHA || process.env.BASE_REF || 'origin/main' const output = execSync(`git diff ${baseRef}...HEAD --name-status`, { encoding: 'utf-8' }) const addedIcons = [] output.split('\n').forEach(line => { if (line.startsWith('A\t')) { const filePath = line.substring(2) // Filter only SVG files from icons/outline/ or icons/filled/ directories if (filePath.match(/^icons\/((outline|filled)\/.+\.svg)$/)) { // add icon without icons/ prefix addedIcons.push(filePath.replace(/^icons\//, '')) } } }) return addedIcons } catch (error) { return [] } } types.forEach(type => { const icons = globSync(join(ICONS_SRC_DIR, type, '*.svg')).sort() icons.forEach((icon) => { const iconContent = fs.readFileSync(icon, 'utf-8'), iconName = getIconName(icon) if (!iconContent.includes(iconTemplate(type))) { console.log(`⛔️ Icon \`${iconName}\` is not properly formatted`) error = true } if (!iconContent.includes('')) { console.log(`⛔️ Icon \`${iconName}\` has no metadata`) error = true } if (iconContent.includes('M0 0h24v24H0z')) { console.log(`⛔️ Icon \`${iconName}\` contains empty rectangle path \`M0 0h24v24H0z\``) error = true } // Check for SVG elements that should be converted to path const invalidElements = [' iconContent.includes(el)) if (foundInvalidElements.length > 0) { console.log(`⛔️ Icon \`${iconName}\` contains elements that should be converted to path: ${foundInvalidElements.join(', ')}`) error = true } // Check for rectangle paths that end with 'z' (should not have closing 'z') // Rectangle paths should have two arc commands next to each other with the same size (rx and ry) const rectanglePathRegex = /]*d=["']([^"']*?)a([\d.]+)\s+([\d.]+)\s+[01]\s+[01]\s([0-9.-]+)\s([0-9.-]+)\s[0-9.-]+a\2\s+\3\s+[01]\s+[01]\s[0-9.-]+\s([0-9.-]+)\s([0-9.-]+)z([^"']*?)["']\s+\/>/g if (rectanglePathRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains rectangle path that ends with 'z' (should not have closing 'z')`) error = true } // Check for path with 'z' followed by h/v/H/V command (invalid pattern) const invalidZCommandRegex = /]*d=["']([^"']*?)z[hvHV]([^"']*?)["']\s+\/>/g if (invalidZCommandRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains path with 'z' followed by h/v/H/V command (invalid pattern)`) error = true } // Check for path with 'm' (relative move) after 'M' (absolute move) const invalidMAfterMRegex = /]*d=["']([^"']*?)M[0-9.-]\s[0-9.-]*?m([^"']*?)["']/g if (invalidMAfterMRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains path with 'm' (relative move) after 'M' (absolute move)`) error = true } // Check for path with 'Z' (uppercase) - disallow Z from path if (type === 'outline') { const invalidZRegex = /]*d=["'][^"']*Z[^"']*["']\s+\/>/gi if (invalidZRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains path with 'Z'`) error = true } } // Check for empty path d="" const emptyPathRegex = /]*d=["']\s*["']/g if (emptyPathRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains empty path d=""`) error = true } // Check for v0 or h0 (forbidden, but v0.1, h0.5 etc. are allowed) const forbiddenV0H0Regex = /]*d="[^"']*[hv]0(?!\.\d)[^"']*"/g if (forbiddenV0H0Regex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains forbidden v0 or h0`) error = true } // Check for path with only M command (empty path) const onlyMRegex = /]*d=["']\s*[Mm][\s0-9.-]+\s*["']/g if (onlyMRegex.test(iconContent)) { console.log(`⛔️ Icon \`${iconName}\` contains path with only M command (empty path)`) error = true } try { const { data } = parseMatter(icon) if (data.unicode) { if (unicodes.indexOf(data.unicode) !== -1) { console.log(`⛔️ Icon \`${iconName}\` has duplicate unicode \`${data.unicode}\``) error = true } if (data.unicode.length !== 4 && data.unicode.length !== 5) { console.log(`⛔️ Icon \`${iconName}\` has invalid unicode \`${data.unicode}\``) error = true } unicodes.push(data.unicode) } else if (argvs.hard) { console.log(`⛔️ Icon \`${iconName}\` has no unicode`) error = true } // check duplicates in tags if (duplicateExists(data.tags || [])) { console.log(`⛔️ Icon \`${iconName}\` has duplicate tags`) error = true } if (argvs.hard && !data.version) { console.log(`⛔️ Icon \`${iconName}\` has no version`) error = true } if (type === 'filled' && data.category) { console.log(`⛔️ Icon \`${iconName}\` has category in filled version`) error = true } if (type === 'filled' && data.tags) { console.log(`⛔️ Icon \`${iconName}\` has tags in filled version`) error = true } } catch (e) { console.log(`⛔️ Icon \`${iconName}\` has invalid metadata`) error = true } }) filledIconsNames.forEach((icon) => { const iconName = getIconName(icon) if (outlineIconsNames.indexOf(icon) === -1) { console.log(`⛔️ Icon \`${iconName}\` exists in filled version but doesn't exists in outline`) error = true } }) }) // check aliases Object.entries(aliases).forEach(([type, replacers]) => { Object.entries(replacers).forEach(([from, to]) => { if (!fs.existsSync(join(ICONS_SRC_DIR, type, `${to}.svg`))) { console.log(`⛔️ Alias \`${type}/${from}\` for \`${type}/${to}\` doesn't exists`) error = true } }) }) const addedIcons = getAddedIconsFromMain() for (const icon of addedIcons) { const iconPath = join(ICONS_SRC_DIR, icon) try { const { data, content } = parseMatter(iconPath) if (data.unicode) { console.log(`⛔️ Icon \`${icon}\` has unicode, but should not have it`) error = true } if (data.version) { console.log(`⛔️ New icon \`${icon}\` has version, but should not have it`) error = true } if (!icon.match(/^(outline|filled)\/[a-z0-9-]+\.svg$/)) { console.log(`⛔️ New icon \`${icon}\` has invalid name`) error = true } // check if outline icon has category if (icon.match(/^outline\//) ) { if(!data.category) { console.log(`⛔️ New outline icon \`${icon}\` has no category`) error = true } else if (!categories.includes(data.category)) { console.log(`⛔️ New outline icon \`${icon}\` has invalid category \`${data.category}\`. Valid categories are: ${categories.join(', ')}`) error = true } } else { if (icon.match(/^filled\//) && data.category) { console.log(`⛔️ New filled icon \`${icon}\` has category, but should not have it`) error = true } } } catch (error) { console.log(`⛔️ New icon \`${icon}\` has invalid metadata`) error = true } } if (error) { process.exit(1) } else { console.log('✅ All icons are valid!') process.exit(0) }