Files
bsky-social-app/scripts/icons/lib.mts
T
2026-08-31 17:14:09 +03:00

694 lines
26 KiB
TypeScript

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 = '<path>') {
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 <style> are not allowed')
}
}
}
}
function inspectSvg(root, file, lane) {
const entries = walkElements(root)
const svgEntries = entries.filter(entry => entry.node.name === 'svg')
const documentElements = (root.children ?? []).filter(child => child.type === 'element')
if (
svgEntries.length !== 1 ||
svgEntries[0].ancestors.length !== 0 ||
documentElements.length !== 1 ||
documentElements[0] !== svgEntries[0].node
) {
fail(file, 'expected exactly one root <svg> element')
}
const viewBox = validateViewBox(svgEntries[0].node.attributes?.viewBox, file)
rejectDangerousContent(entries, file, {raw: lane === 'custom'})
if (lane === 'custom') {
const safeRawElements = new Set([
'circle',
'clipPath',
'defs',
'ellipse',
'g',
'line',
'linearGradient',
'path',
'polygon',
'polyline',
'radialGradient',
'rect',
'stop',
'style',
'svg',
])
const unsupported = entries.find(entry => !safeRawElements.has(entry.node.name))
if (unsupported) fail(file, `unsupported <${unsupported.node.name}> element in custom/`)
}
for (const entry of entries.filter(entry => entry.node.name === 'path')) {
validatePathData(entry.node.attributes?.d, file)
}
return {entries, viewBox}
}
function validateOrdinaryAttributes(entries, file, lane) {
const presentation = new Set([
'clip-rule',
'fill',
'fill-rule',
'stroke',
'stroke-linecap',
'stroke-linejoin',
'stroke-width',
])
const allowedByElement = {
g: presentation,
path: new Set(['d', ...presentation]),
svg: new Set(['viewBox', 'xmlns', ...presentation]),
}
for (const {node} of entries) {
const allowed = allowedByElement[node.name]
for (const [name, value] of Object.entries(node.attributes ?? {})) {
if (!allowed.has(name)) {
fail(file, `unsupported ${name} attribute on <${node.name}> in ${lane}/`)
}
if ((name === 'fill-rule' || name === 'clip-rule') && value !== 'evenodd') {
fail(file, `unsupported ${name} ${JSON.stringify(value)} in ${lane}/`)
}
}
}
}
function validateOrdinarySourceSemantics(entries, file, lane) {
for (const entry of entries.filter(entry => entry.node.name === 'path')) {
const attributes = effectiveAttributes(entry)
for (const name of ['fill-rule', 'clip-rule']) {
if (attributes[name] && attributes[name] !== 'evenodd') {
fail(file, `unsupported ${name} ${JSON.stringify(attributes[name])} in ${lane}/`)
}
}
}
}
function validateOrdinaryIcon(root, file, lane) {
const {entries, viewBox} = inspectSvg(root, file, lane)
const allowed = new Set(['g', 'path', 'svg'])
const unsupported = entries.find(entry => !allowed.has(entry.node.name))
if (unsupported) fail(file, `unsupported <${unsupported.node.name}> element in ${lane}/`)
validateOrdinaryAttributes(entries, file, lane)
const paths = entries.filter(entry => entry.node.name === 'path')
if (paths.length !== 1) {
fail(
file,
`${lane}/ generated icons must optimize to exactly one path; found ${paths.length} (move raw multi-path artwork to custom/)`,
)
}
const attributes = effectiveAttributes(paths[0])
const fill = attributes.fill ?? 'black'
const stroke = attributes.stroke ?? 'none'
const hasFill = fill !== 'none'
const hasStroke = stroke !== 'none'
if (hasFill === hasStroke) fail(file, 'each path must use either fill or stroke, not both')
let description
if (hasStroke) {
const strokeWidth = Number(attributes['stroke-width'] ?? 1)
if (![1, 1.5, 2].includes(strokeWidth)) fail(file, `unsupported stroke-width ${strokeWidth}`)
const strokeLinecap = attributes['stroke-linecap'] ?? 'butt'
const strokeLinejoin = attributes['stroke-linejoin'] ?? 'miter'
if (!['butt', 'round', 'square'].includes(strokeLinecap)) fail(file, `unsupported stroke-linecap ${strokeLinecap}`)
if (!['bevel', 'miter', 'round'].includes(strokeLinejoin)) fail(file, `unsupported stroke-linejoin ${strokeLinejoin}`)
description = {path: attributes.d, strokeLinecap, strokeLinejoin, strokeWidth}
} else {
description = {path: attributes.d, strokeWidth: 0}
}
return {description, viewBox}
}
function validateFlexibleIcon(root, file, lane) {
const {entries, viewBox} = inspectSvg(root, file, lane)
const allowed = new Set(['circle', 'g', 'path', 'rect', 'svg'])
const unsupported = entries.find(entry => !allowed.has(entry.node.name))
if (unsupported) fail(file, `unsupported <${unsupported.node.name}> element in ${lane}/`)
const drawable = entries.filter(entry => ['circle', 'path', 'rect'].includes(entry.node.name))
if (drawable.length === 0) fail(file, 'icon has no drawable elements after optimization')
const elements = drawable.map(entry => {
const attributes = effectiveAttributes(entry)
const common = {
fill: attributes.fill ?? 'black',
stroke: attributes.stroke ?? 'none',
strokeLinecap: attributes['stroke-linecap'],
strokeLinejoin: attributes['stroke-linejoin'],
strokeWidth: attributes['stroke-width'] ? Number(attributes['stroke-width']) : undefined,
type: entry.node.name,
}
if (entry.node.name === 'path') {
return {...common, d: attributes.d, fillRule: attributes['fill-rule'], clipRule: attributes['clip-rule']}
}
if (entry.node.name === 'circle') {
return {...common, cx: Number(attributes.cx ?? 0), cy: Number(attributes.cy ?? 0), r: Number(attributes.r)}
}
return {
...common,
height: Number(attributes.height),
rx: attributes.rx ? Number(attributes.rx) : undefined,
ry: attributes.ry ? Number(attributes.ry) : undefined,
width: Number(attributes.width),
x: Number(attributes.x ?? 0),
y: Number(attributes.y ?? 0),
}
})
if (elements.some(element => Object.values(element).some(value => typeof value === 'number' && !Number.isFinite(value)))) {
fail(file, 'shape has invalid numeric attributes')
}
return {elements, viewBox}
}
export function tokensForSemanticName(name) {
return name.match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+/g) ?? []
}
export function semanticName(exportName) {
const parts = exportName.replaceAll('Stoke', 'Stroke').split('_')
const styleIndex = parts.findIndex(part => STYLE_TOKEN.test(part))
return (styleIndex === -1 ? parts : parts.slice(0, styleIndex)).join('')
}
export function normalizeExportName(exportName) {
const parts = exportName.replaceAll('Stoke', 'Stroke').split('_')
const styleIndex = parts.findIndex(part => STYLE_TOKEN.test(part))
if (styleIndex === -1) return parts.join('')
return [parts.slice(0, styleIndex).join(''), ...parts.slice(styleIndex)].join('_')
}
function validateUiExportName(exportName, file) {
const parts = exportName.split('_')
const styleIndex = parts.findIndex(part => STYLE_TOKEN.test(part))
const styles = styleIndex === -1 ? [] : parts.slice(styleIndex)
const hasVariant = styles.some(part => part === 'Filled' || /^Stroke\d+$/.test(part))
const hasCorner = styles.some(part => /^Corner\d+$/.test(part))
if (
styleIndex <= 0 ||
styles.some(part => !STYLE_TOKEN.test(part)) ||
!hasVariant ||
!hasCorner ||
!styles.includes('Rounded')
) {
fail(
file,
'UI icon names must have a semantic name followed only by style tokens, including Filled or StrokeN, CornerN, and Rounded',
)
}
}
function validateUiSizeName(exportName, viewBox, file) {
const [, , width, height] = viewBox.split(' ').map(Number)
const isLarge = width === 64 && height === 64
const namedLarge = exportName.split('_').includes('Large')
if (isLarge !== namedLarge) {
fail(file, 'the Large style token must be present if and only if the viewBox is 64x64')
}
}
export function assignFamilies(icons) {
const byNamespace = Map.groupBy(icons, icon => icon.namespace)
for (const namespaceIcons of byNamespace.values()) {
const buckets = Map.groupBy(namespaceIcons, icon => tokensForSemanticName(semanticName(icon.exportName))[0])
for (const bucket of buckets.values()) {
const distinct = [...new Map(bucket.map(icon => [semanticName(icon.exportName), tokensForSemanticName(semanticName(icon.exportName))])).values()]
let prefix = distinct[0]
if (distinct.length > 1) {
prefix = prefix.slice(0, distinct.reduce((length, tokens) => {
let index = 0
while (index < length && prefix[index] === tokens[index]) index += 1
return index
}, prefix.length))
}
if (prefix.length === 0) throw new Error(`could not derive a family for ${bucket.map(icon => icon.exportName).join(', ')}`)
const family = prefix.join('')
for (const icon of bucket) icon.family = family
}
}
return icons
}
function namespaceForLane(lane) {
if (lane === 'brands' || lane === 'community') return lane
return ''
}
function cleanObject(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
}
export async function readIconSource(sourceRoot, relativePath) {
const [lane, ...rest] = relativePath.split(path.sep)
if (!SOURCE_LANES.has(lane)) fail(relativePath, `unclassified source directory ${lane}`)
if (rest.length !== 1) fail(relativePath, 'nested icon source directories are not supported')
const filename = rest[0]
const source = await fs.readFile(path.join(sourceRoot, relativePath), 'utf8')
let originalRoot
let optimizedRoot
let result
try {
result = optimize(source, {
...svgoConfig,
path: relativePath,
plugins: [
{name: 'captureOriginal', fn(root) { originalRoot = structuredClone(root) }},
...svgoConfig.plugins,
{name: 'captureOptimized', fn(root) { optimizedRoot = structuredClone(root) }},
],
})
} catch (error) {
fail(relativePath, `malformed SVG: ${error.message}`)
}
const original = inspectSvg(originalRoot, relativePath, lane)
if (!CODEGEN_LANES.has(lane)) {
return {codegen: false, lane, optimized: result.data, relativePath, warnings: []}
}
const exportName = filename.slice(0, -path.extname(filename).length)
if (path.extname(filename) !== '.svg') fail(relativePath, 'icon sources must use the .svg extension')
if (!IDENTIFIER.test(exportName)) fail(relativePath, `filename must be a valid TypeScript export name; got ${exportName}`)
if (!/^[A-Z]/.test(exportName)) fail(relativePath, 'generated icon filenames must begin with an uppercase letter')
if (normalizeExportName(exportName) !== exportName) {
fail(relativePath, `non-canonical export name; rename it to ${normalizeExportName(exportName)}.svg`)
}
if (lane === 'ui') {
validateUiExportName(exportName, relativePath)
validateUiSizeName(exportName, original.viewBox, relativePath)
}
if (lane !== 'brands') validateOrdinarySourceSemantics(original.entries, relativePath, lane)
const data = lane === 'brands' ? validateFlexibleIcon(optimizedRoot, relativePath, lane) : validateOrdinaryIcon(optimizedRoot, relativePath, lane)
const [, , width, height] = data.viewBox.split(' ').map(Number)
const warnings =
(width === 24 && height === 24) || (width === 64 && height === 64)
? []
: [`${relativePath}: non-standard viewBox ${data.viewBox}; expected 24x24 or 64x64`]
return {
...data,
codegen: true,
exportName,
lane,
namespace: namespaceForLane(lane),
optimized: result.data,
relativePath,
warnings,
}
}
async function listFiles(root) {
const result = []
async function visit(directory, prefix = '') {
for (const entry of await fs.readdir(directory, {withFileTypes: true})) {
const relative = path.join(prefix, entry.name)
if (entry.isDirectory()) await visit(path.join(directory, entry.name), relative)
else result.push(relative)
}
}
await visit(root)
return result.sort()
}
export async function discoverIconSources(sourceRoot) {
const files = await listFiles(sourceRoot)
const unclassified = files.filter(file => file.endsWith('.svg') && !SOURCE_LANES.has(file.split(path.sep)[0]))
if (unclassified.length > 0) throw new Error(`unclassified SVG sources:\n${unclassified.join('\n')}`)
return files.filter(file => file.endsWith('.svg') && file.split(path.sep)[0] !== 'flags')
}
function relativeImport(fromModule, targetModule) {
let result = path.posix.relative(path.posix.dirname(fromModule), targetModule)
if (!result.startsWith('.')) result = `./${result}`
return result
}
function renderSingleIcon(icon) {
const description = icon.description
const properties = [`path: ${JSON.stringify(description.path)}`, `viewBox: ${JSON.stringify(icon.viewBox)}`]
if (description.strokeWidth > 0) {
properties.push(`strokeWidth: ${description.strokeWidth}`)
if (description.strokeLinecap !== 'butt') properties.push(`strokeLinecap: ${JSON.stringify(description.strokeLinecap)}`)
if (description.strokeLinejoin !== 'miter') properties.push(`strokeLinejoin: ${JSON.stringify(description.strokeLinejoin)}`)
}
return `export const ${icon.exportName} = createSinglePathSVG({\n ${properties.join(',\n ')},\n})\n`
}
function renderFlexibleIcon(icon) {
return `export const ${icon.exportName} = createSVG({\n elements: ${JSON.stringify(icon.elements.map(cleanObject), null, 2).replaceAll('\n', '\n ')},\n viewBox: ${JSON.stringify(icon.viewBox)},\n})\n`
}
function renderModule(modulePath, icons, aliases) {
const factoryNames = new Set()
const imports = []
for (const icon of icons) {
if (icon.elements) factoryNames.add('createSVG')
else factoryNames.add('createSinglePathSVG')
}
const templatePath = relativeImport(modulePath, 'TEMPLATE')
const sections = [GENERATED_HEADER.trimEnd()]
if (factoryNames.size > 0) {
imports.push({
code: `import {${[...factoryNames].sort().join(', ')}} from ${JSON.stringify(templatePath)}`,
source: templatePath,
})
}
for (const alias of aliases) {
if (alias.targetModule === modulePath) continue
const targetPath = relativeImport(modulePath, alias.targetModule)
imports.push({
code: `import {${alias.targetExport} as ${alias.localName}} from ${JSON.stringify(targetPath)}`,
source: targetPath,
})
}
if (imports.length > 0) {
sections.push(
imports
.sort((a, b) => a.source.localeCompare(b.source) || a.code.localeCompare(b.code))
.map(entry => entry.code)
.join('\n'),
)
}
for (const icon of icons) {
sections.push(icon.elements ? renderFlexibleIcon(icon) : renderSingleIcon(icon))
}
for (const alias of aliases) {
sections.push(`/** @deprecated Import ${alias.targetExport} from \`#/components/icons/${alias.targetModule}\` instead. */\nexport const ${alias.exportName} = ${alias.localName}\n`)
}
return `${sections.join('\n\n')}\n`
}
export async function scanIconImports(scanRoot, outputRoot) {
const files = (await listFiles(scanRoot)).filter(file => /\.[cm]?[jt]sx?$/.test(file))
const outputRelative = path.relative(scanRoot, outputRoot)
const imports = []
for (const relative of files) {
if (relative === outputRelative || relative.startsWith(`${outputRelative}${path.sep}`)) continue
const contents = await fs.readFile(path.join(scanRoot, relative), 'utf8')
for (const match of contents.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]#\/components\/icons\/([^'"]+)['"]/g)) {
const modulePath = match[2]
if (!modulePath.split('/').every(part => IDENTIFIER.test(part))) {
fail(relative, `invalid icon module path ${JSON.stringify(modulePath)}`)
}
for (const raw of match[1].split(',')) {
const specifier = raw.trim().replace(/^type\s+/, '')
if (!specifier) continue
const exportName = specifier.split(/\s+as\s+/)[0].trim()
if (IDENTIFIER.test(exportName)) imports.push({exportName, modulePath, sourceFile: relative})
}
}
}
return imports
}
export async function buildIconSet({outputRoot, scanRoot, sourceRoot}) {
const sourcePaths = await discoverIconSources(sourceRoot)
const sources = await Promise.all(sourcePaths.map(relative => readIconSource(sourceRoot, relative)))
const icons = assignFamilies(sources.filter(source => source.codegen))
const exportMap = new Map()
const normalizedExportMap = new Map()
for (const icon of icons) {
if (exportMap.has(icon.exportName)) {
fail(icon.relativePath, `duplicate export ${icon.exportName} also provided by ${exportMap.get(icon.exportName).relativePath}`)
}
icon.modulePath = path.posix.join(icon.namespace, icon.family)
exportMap.set(icon.exportName, icon)
const normalized = normalizeExportName(icon.exportName)
if (normalizedExportMap.has(normalized)) {
fail(icon.relativePath, `normalized export collision with ${normalizedExportMap.get(normalized).relativePath}`)
}
normalizedExportMap.set(normalized, icon)
}
const imports = await scanIconImports(scanRoot, outputRoot)
const modules = new Map()
function moduleData(modulePath) {
if (!modules.has(modulePath)) modules.set(modulePath, {aliases: [], icons: []})
return modules.get(modulePath)
}
for (const icon of icons) moduleData(icon.modulePath).icons.push(icon)
const deprecatedImports = []
const holdouts = []
const aliasKeys = new Set()
for (const imported of imports) {
const icon = exportMap.get(imported.exportName) ?? normalizedExportMap.get(normalizeExportName(imported.exportName))
if (!icon || (imported.modulePath === icon.modulePath && imported.exportName === icon.exportName)) continue
deprecatedImports.push({...imported, targetModule: icon.modulePath})
const legacyFile = path.join(outputRoot, `${imported.modulePath}.tsx`)
let handwritten = false
try {
const contents = await fs.readFile(legacyFile, 'utf8')
handwritten = !contents.startsWith(GENERATED_HEADER)
} catch {}
if (handwritten && !modules.has(imported.modulePath)) {
holdouts.push({...imported, targetModule: icon.modulePath})
continue
}
const key = `${imported.modulePath}:${imported.exportName}`
if (aliasKeys.has(key)) continue
aliasKeys.add(key)
moduleData(imported.modulePath).aliases.push({
exportName: imported.exportName,
localName: imported.modulePath === icon.modulePath ? icon.exportName : `Canonical${imported.exportName}`,
targetExport: icon.exportName,
targetModule: icon.modulePath,
})
}
const tsOutputs = new Map()
for (const [modulePath, data] of modules) {
if (data.icons.length > 0) {
const target = path.join(outputRoot, `${modulePath}.tsx`)
try {
const contents = await fs.readFile(target, 'utf8')
if (!contents.startsWith(GENERATED_HEADER)) {
fail(
path.relative(process.cwd(), target),
'generated output would overwrite a handwritten module; rename the SVG family or move the handwritten code',
)
}
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
}
data.icons.sort((a, b) => a.exportName.localeCompare(b.exportName))
data.aliases.sort((a, b) => a.exportName.localeCompare(b.exportName))
tsOutputs.set(
`${modulePath}.tsx`,
await format(renderModule(modulePath, data.icons, data.aliases), {
arrowParens: 'avoid',
bracketSameLine: true,
bracketSpacing: false,
parser: 'typescript',
semi: false,
singleQuote: true,
trailingComma: 'all',
}),
)
}
const svgOutputs = new Map(sources.map(source => [source.relativePath, source.optimized]))
const warnings = sources.flatMap(source => source.warnings)
return {deprecatedImports, holdouts, icons, svgOutputs, tsOutputs, warnings}
}
export async function applyIconSet({check, outputRoot, result, sourceRoot}) {
const differences = []
async function compareOrWrite(root, relative, contents) {
const target = path.join(root, relative)
let current
try { current = await fs.readFile(target, 'utf8') } catch {}
if (current === contents) return
differences.push(path.relative(process.cwd(), target))
if (!check) {
await fs.mkdir(path.dirname(target), {recursive: true})
await fs.writeFile(target, contents)
}
}
for (const [relative, contents] of result.svgOutputs) await compareOrWrite(sourceRoot, relative, contents)
for (const [relative, contents] of result.tsOutputs) await compareOrWrite(outputRoot, relative, contents)
const existing = await listFiles(outputRoot)
for (const relative of existing.filter(file => file.endsWith('.tsx') && !result.tsOutputs.has(file))) {
const target = path.join(outputRoot, relative)
const contents = await fs.readFile(target, 'utf8')
if (!contents.startsWith(GENERATED_HEADER)) continue
differences.push(path.relative(process.cwd(), target))
if (!check) await fs.unlink(target)
}
return [...new Set(differences)].sort()
}