import fs from 'node:fs/promises' import path from 'node:path' import {format} from 'prettier' import {optimize} from 'svgo' import svgoConfig from '../../svgo.config.mjs' export const GENERATED_HEADER = '// This file is generated by pnpm icons:generate. Do not edit.\n' const STYLE_TOKEN = /^(?:Filled|Stroke\d*|Corner\d+|Rounded|Large)$/ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/ const NUMBER = '[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?' const PATH_TOKEN = new RegExp(`[A-Za-z]|${NUMBER}`, 'y') const PATH_ARGUMENTS = { A: 7, C: 6, H: 1, L: 2, M: 2, Q: 4, S: 4, T: 2, V: 1, Z: 0, } export const SOURCE_LANES = new Set([ 'brands', 'community', 'custom', 'flags', 'ui', ]) const CODEGEN_LANES = new Set(['brands', 'community', 'ui']) function fail(file, message) { throw new Error(`${file}: ${message}`) } function walkElements(node, ancestors = [], result = []) { if (node.type === 'element') { result.push({node, ancestors}) ancestors = [...ancestors, node] } for (const child of node.children ?? []) { walkElements(child, ancestors, result) } return result } function effectiveAttributes(entry) { return Object.assign( {}, ...entry.ancestors.map(node => node.attributes ?? {}), entry.node.attributes ?? {}, ) } function tokenizePathData(data, file) { const tokens = [] let index = 0 while (index < data.length) { const whitespace = /^\s+/.exec(data.slice(index)) if (whitespace) index += whitespace[0].length if (index >= data.length) break if (data[index] === ',') { if (typeof tokens.at(-1) !== 'number') { fail(file, `misplaced comma near ${JSON.stringify(data.slice(index, index + 16))}`) } index += 1 const afterComma = /^\s*/.exec(data.slice(index)) index += afterComma[0].length PATH_TOKEN.lastIndex = index const next = PATH_TOKEN.exec(data) if (!next || /[A-Za-z]/.test(next[0])) { fail(file, `misplaced comma near ${JSON.stringify(data.slice(index, index + 16))}`) } } PATH_TOKEN.lastIndex = index const match = PATH_TOKEN.exec(data) if (!match) fail(file, `malformed path data near ${JSON.stringify(data.slice(index, index + 16))}`) tokens.push(/[A-Za-z]/.test(match[0]) ? match[0] : Number(match[0])) index = PATH_TOKEN.lastIndex } return tokens } function rejectExternalUrls(value, file) { for (const match of value.matchAll(/url\s*\(\s*(?:(['"])(.*?)\1|([^)]*))\s*\)/gi)) { const target = (match[2] ?? match[3]).trim() if (!target.startsWith('#')) fail(file, `external URL ${JSON.stringify(target)} is not allowed`) } } export function validatePathData(data, file = '') { if (typeof data !== 'string' || data.trim() === '') fail(file, 'path data is empty') const tokens = tokenizePathData(data, file) if (tokens[0] !== 'M' && tokens[0] !== 'm') fail(file, 'path data must begin with M or m') let index = 0 let command while (index < tokens.length) { if (typeof tokens[index] === 'string') { command = tokens[index++] const upper = command.toUpperCase() if (!(upper in PATH_ARGUMENTS)) fail(file, `unsupported path command ${command}`) if (upper === 'Z') { command = undefined continue } } else if (!command) { fail(file, 'path arguments are missing a command') } const upper = command.toUpperCase() const argumentCount = PATH_ARGUMENTS[upper] let sets = 0 while (index < tokens.length && typeof tokens[index] !== 'string') { if (index + argumentCount > tokens.length) fail(file, `${command} has too few arguments`) const args = tokens.slice(index, index + argumentCount) if (args.some(value => typeof value !== 'number' || !Number.isFinite(value))) { fail(file, `${command} has an invalid number`) } if (upper === 'A' && ![0, 1].includes(args[3])) fail(file, 'arc large-arc-flag must be 0 or 1') if (upper === 'A' && ![0, 1].includes(args[4])) fail(file, 'arc sweep-flag must be 0 or 1') index += argumentCount sets += 1 } if (sets === 0) fail(file, `${command} has no arguments`) } } function validateViewBox(value, file) { if (!value) fail(file, 'missing viewBox') const numbers = value.trim().split(/[\s,]+/).map(Number) if (numbers.length !== 4 || numbers.some(number => !Number.isFinite(number))) { fail(file, `invalid viewBox ${JSON.stringify(value)}`) } if (numbers[2] <= 0 || numbers[3] <= 0) fail(file, 'viewBox width and height must be positive') return numbers.join(' ') } function rejectDangerousContent(entries, file, {raw = false} = {}) { for (const {node} of entries) { if ( ['foreignObject', 'image', 'script', 'text', 'use'].includes(node.name) || (!raw && node.name === 'style') ) { fail(file, `unsupported <${node.name}> element`) } for (const [name, value] of Object.entries(node.attributes ?? {})) { if (/^on/i.test(name)) fail(file, `event handler attribute ${name} is not allowed`) if ((name === 'href' || name === 'xlink:href') && !value.startsWith('#')) { fail(file, `external reference ${JSON.stringify(value)} is not allowed`) } rejectExternalUrls(value, file) } if (node.name === 'style') { const css = (node.children ?? []) .filter(child => child.type === 'text') .map(child => child.value) .join('') rejectExternalUrls(css, file) if (/@import/i.test(css)) { fail(file, 'external resources in