add icon codegen

This commit is contained in:
Samuel Newman
2026-08-14 22:10:06 +03:00
parent 89c8e1cb70
commit c6adfbb1fb
490 changed files with 2777 additions and 661 deletions
+37
View File
@@ -0,0 +1,37 @@
# Icon codegen
SVG files under `assets/icons/` are the source of truth. Generated components under
`src/components/icons/` are committed so icon changes stay visible in review.
## Workflow
1. Name the SVG after its exact TypeScript export, for example
`ArrowTop_Stroke2_Corner0_Rounded.svg`.
Files in `ui/` must use a semantic name followed only by style tokens and include `Filled` or
`StrokeN`, `CornerN`, and `Rounded`. Brand and community marks are exempt because their public
names are not UI-style variants.
2. Put it in the directory whose policy it needs:
- `ui/` — strict monochrome icons; exactly one optimized path
- `brands/` — brand marks that may preserve multiple paint roles or basic shapes
- `community/` — third-party marks
- `custom/` — raw assets, including multi-path exceptions, that are optimized but not
component-generated
- `flags/` — runtime assets, excluded from codegen and optimization
3. Run `pnpm icons:generate` and commit both the SVG and generated TypeScript.
`pnpm icons:check` verifies that optimized SVGs and generated TypeScript are current.
`pnpm icons:test` runs the focused generator tests. `pnpm icons:optimize` remains an alias for
generation during the workflow transition. Generation warns, but does not fail, when a generated
icon uses a viewBox other than 24×24 or 64×64.
## Output grouping
Grouping has no per-icon manifest. The generator removes style suffixes, tokenizes semantic
names, buckets them by the first token, and uses the longest shared token prefix as the module
family. A singleton uses its complete semantic name. Brand and community modules remain in
their own namespaces.
When an existing application import points at an older module, codegen emits a deprecated
constant alias at that path. This preserves component identity while making the canonical import
visible to editors. Run `pnpm icons:generate -- --verbose` to list the remaining deprecated
imports and their locations.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import {applyIconSet, buildIconSet} from './lib.mts'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const check = process.argv.includes('--check')
const verbose = process.argv.includes('--verbose')
const sourceRoot = path.join(repoRoot, 'assets/icons')
const outputRoot = path.join(repoRoot, 'src/components/icons')
try {
const result = await buildIconSet({outputRoot, scanRoot: path.join(repoRoot, 'src'), sourceRoot})
const differences = await applyIconSet({check, outputRoot, result, sourceRoot})
if (check && differences.length > 0) {
console.error(`Icon codegen is stale. Run pnpm icons:generate:\n${differences.map(file => `- ${file}`).join('\n')}`)
process.exitCode = 1
} else {
const verb = check ? 'checked' : 'generated'
console.log(`${verb} ${result.icons.length} icons in ${result.tsOutputs.size} modules`)
if (result.warnings.length > 0) {
console.warn(
`${result.warnings.length} icons use a non-standard viewBox:\n${result.warnings.map(warning => `- ${warning}`).join('\n')}`,
)
}
console.log(`${result.deprecatedImports.length} deprecated imports remain`)
if (result.deprecatedImports.length > 0 && !verbose) {
console.log('run with --verbose to list deprecated import locations')
}
if (verbose) {
for (const imported of result.deprecatedImports) {
console.log(
`- ${imported.sourceFile}: ${imported.modulePath}.${imported.exportName} -> ${imported.targetModule}`,
)
}
}
if (result.holdouts.length > 0) {
console.warn(`${result.holdouts.length} compatibility aliases belong to handwritten modules and must be maintained there`)
for (const holdout of result.holdouts) {
console.warn(
`- ${holdout.sourceFile}: ${holdout.modulePath}.${holdout.exportName} -> ${holdout.targetModule}`,
)
}
}
}
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
+693
View File
@@ -0,0 +1,693 @@
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()
}
+307
View File
@@ -0,0 +1,307 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import {
applyIconSet,
assignFamilies,
buildIconSet,
normalizeExportName,
readIconSource,
validatePathData,
} from './lib.mts'
async function fixture(t, lane, name, svg) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'icon-codegen-'))
t.after(() => fs.rm(root, {recursive: true, force: true}))
await fs.mkdir(path.join(root, lane), {recursive: true})
await fs.writeFile(path.join(root, lane, name), svg)
return {relative: path.join(lane, name), root}
}
test('validates SVG path grammar', () => {
validatePathData('M0 0h2v2H0Z')
validatePathData('M0,0 2,2')
assert.throws(() => validatePathData('M0 0 L nope'), /L has no arguments/)
assert.throws(() => validatePathData('M0'), /too few arguments/)
assert.throws(() => validatePathData('M0 0A1 1 0 2 0 3 4'), /large-arc-flag/)
assert.throws(() => validatePathData('M0,,0'), /misplaced comma/)
assert.throws(() => validatePathData('M,0 0'), /misplaced comma/)
assert.throws(() => validatePathData('M0 0,'), /misplaced comma/)
})
test('normalizes accidental legacy export spelling', () => {
assert.equal(
normalizeExportName('Envelope_Open_Stoke2_Corner0_Rounded'),
'EnvelopeOpen_Stroke2_Corner0_Rounded',
)
})
test('groups families deterministically', () => {
const icons = [
{exportName: 'CircleCheck_Stroke2_Corner0_Rounded', namespace: ''},
{exportName: 'CircleInfo_Stroke2_Corner0_Rounded', namespace: ''},
{exportName: 'MagnifyingGlass_Stroke2_Corner0_Rounded', namespace: ''},
{exportName: 'MagnifyingGlassX_Stroke2_Corner0_Rounded', namespace: ''},
]
assignFamilies(icons)
assert.deepEqual(icons.map(icon => icon.family), [
'Circle',
'Circle',
'MagnifyingGlass',
'MagnifyingGlass',
])
})
test('accepts fill and supported stroke icons', async t => {
const fill = await fixture(
t,
'ui',
'Fill_Filled_Corner0_Rounded.svg',
'<svg viewBox="0 0 24 24"><path d="M0 0h2v2Z"/></svg>',
)
assert.equal((await readIconSource(fill.root, fill.relative)).description.strokeWidth, 0)
const stroke = await fixture(
t,
'ui',
'StrokeIcon_Stroke2_Corner0_Rounded.svg',
'<svg fill="none" viewBox="0 0 24 24"><path d="M0 0h2" stroke="#000" stroke-width="1.5" stroke-linecap="round"/></svg>',
)
assert.equal((await readIconSource(stroke.root, stroke.relative)).description.strokeWidth, 1.5)
const nonstandard = await fixture(
t,
'ui',
'Nonstandard_Stroke2_Corner0_Rounded.svg',
'<svg viewBox="0 0 20 18"><path d="M0 0h2"/></svg>',
)
assert.deepEqual((await readIconSource(nonstandard.root, nonstandard.relative)).warnings, [
`${nonstandard.relative}: non-standard viewBox 0 0 20 18; expected 24x24 or 64x64`,
])
})
test('rejects malformed and suspicious SVG input', async t => {
const malformed = await fixture(t, 'ui', 'Bad.svg', '<svg viewBox="0 0 24 24"><path></svg>')
await assert.rejects(readIconSource(malformed.root, malformed.relative), /malformed SVG/)
const noViewBox = await fixture(t, 'ui', 'NoViewBox.svg', '<svg><path d="M0 0h2"/></svg>')
await assert.rejects(readIconSource(noViewBox.root, noViewBox.relative), /missing viewBox/)
const invalidPath = await fixture(
t,
'ui',
'InvalidPath.svg',
'<svg viewBox="0 0 24 24"><path d="M0 0 L nope"/></svg>',
)
await assert.rejects(readIconSource(invalidPath.root, invalidPath.relative), /L has no arguments/)
const external = await fixture(
t,
'ui',
'External.svg',
'<svg viewBox="0 0 24 24"><image href="https://example.com/a.png"/></svg>',
)
await assert.rejects(readIconSource(external.root, external.relative), /unsupported <image>/)
const script = await fixture(
t,
'custom',
'Raw.svg',
'<svg viewBox="0 0 24 24"><script>alert(1)</script><path d="M0 0h2"/></svg>',
)
await assert.rejects(readIconSource(script.root, script.relative), /unsupported <script>/)
const nestedSvg = await fixture(
t,
'ui',
'Nested.svg',
'<g><svg viewBox="0 0 24 24"><path d="M0 0h2"/></svg></g>',
)
await assert.rejects(readIconSource(nestedSvg.root, nestedSvg.relative), /root <svg>/)
const droppedPresentation = await fixture(
t,
'ui',
'Opacity_Filled_Corner0_Rounded.svg',
'<svg viewBox="0 0 24 24"><path opacity=".5" d="M0 0h2"/></svg>',
)
await assert.rejects(readIconSource(droppedPresentation.root, droppedPresentation.relative), /unsupported opacity attribute/)
const unsupportedFillRule = await fixture(
t,
'ui',
'FillRule_Filled_Corner0_Rounded.svg',
'<svg viewBox="0 0 24 24"><path fill-rule="nonzero" d="M0 0h2"/></svg>',
)
await assert.rejects(readIconSource(unsupportedFillRule.root, unsupportedFillRule.relative), /unsupported fill-rule/)
const externalCss = await fixture(
t,
'custom',
'ExternalCss.svg',
'<svg viewBox="0 0 24 24"><style>.x{fill:URL(data:image/svg+xml,bad)}</style><path d="M0 0h2"/></svg>',
)
await assert.rejects(readIconSource(externalCss.root, externalCss.relative), /external URL/)
})
test('keeps multi-path artwork out of generated icon lanes', async t => {
const svg = '<svg viewBox="0 0 24 24"><path fill="#000" d="M0 0h2v2Z"/><path fill="#111" d="M4 4h2v2Z"/></svg>'
const ui = await fixture(t, 'ui', 'Multiple_Filled_Corner0_Rounded.svg', svg)
await assert.rejects(readIconSource(ui.root, ui.relative), /must optimize to exactly one path/)
const custom = await fixture(t, 'custom', 'Multiple.svg', svg)
assert.equal((await readIconSource(custom.root, custom.relative)).codegen, false)
const formerLane = await fixture(t, 'multipath', 'Multiple.svg', svg)
await assert.rejects(readIconSource(formerLane.root, formerLane.relative), /unclassified source directory multipath/)
})
test('requires UI filenames to follow the icon style scheme', async t => {
const svg = '<svg viewBox="0 0 24 24"><path d="M0 0h2v2Z"/></svg>'
const bare = await fixture(t, 'ui', 'Reply.svg', svg)
await assert.rejects(readIconSource(bare.root, bare.relative), /UI icon names must/)
const incomplete = await fixture(t, 'ui', 'Bot_Filled.svg', svg)
await assert.rejects(readIconSource(incomplete.root, incomplete.relative), /UI icon names must/)
const dollarInitial = await fixture(t, 'ui', '$Reply_Stroke2_Corner0_Rounded.svg', svg)
await assert.rejects(readIconSource(dollarInitial.root, dollarInitial.relative), /uppercase letter/)
const underscoreInitial = await fixture(t, 'ui', '_Filled_Corner0_Rounded.svg', svg)
await assert.rejects(readIconSource(underscoreInitial.root, underscoreInitial.relative), /uppercase letter/)
const falseLarge = await fixture(t, 'ui', 'Small_Stroke2_Corner0_Rounded_Large.svg', svg)
await assert.rejects(readIconSource(falseLarge.root, falseLarge.relative), /viewBox is 64x64/)
const missingLarge = await fixture(
t,
'ui',
'Big_Stroke2_Corner0_Rounded.svg',
'<svg viewBox="0 0 64 64"><path d="M0 0h2v2Z"/></svg>',
)
await assert.rejects(readIconSource(missingLarge.root, missingLarge.relative), /viewBox is 64x64/)
})
test('preserves supported brand shapes and paint roles', async t => {
const brand = await fixture(
t,
'brands',
'Badge.svg',
'<svg fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor"/><path fill="#fff" d="M4 4h2v2Z"/></svg>',
)
const icon = await readIconSource(brand.root, brand.relative)
assert.deepEqual(icon.elements.map(element => [element.type, element.fill]), [
['circle', 'currentColor'],
['path', '#fff'],
])
})
test('detects export collisions', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'icon-codegen-'))
t.after(() => fs.rm(root, {recursive: true, force: true}))
const sourceRoot = path.join(root, 'assets/icons')
const scanRoot = path.join(root, 'src')
const outputRoot = path.join(scanRoot, 'components/icons')
await fs.mkdir(path.join(sourceRoot, 'ui'), {recursive: true})
await fs.mkdir(path.join(sourceRoot, 'brands'), {recursive: true})
await fs.mkdir(scanRoot, {recursive: true})
const svg = '<svg viewBox="0 0 24 24"><path d="M0 0h2v2Z"/></svg>'
await fs.writeFile(path.join(sourceRoot, 'ui/Same_Stroke2_Corner0_Rounded.svg'), svg)
await fs.writeFile(path.join(sourceRoot, 'brands/Same_Stroke2_Corner0_Rounded.svg'), svg)
await assert.rejects(
buildIconSet({outputRoot, scanRoot, sourceRoot}),
/duplicate export Same_Stroke2_Corner0_Rounded/,
)
})
test('rejects unsafe legacy import module paths', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'icon-codegen-'))
t.after(() => fs.rm(root, {recursive: true, force: true}))
const sourceRoot = path.join(root, 'assets/icons')
const scanRoot = path.join(root, 'src')
const outputRoot = path.join(scanRoot, 'components/icons')
await fs.mkdir(path.join(sourceRoot, 'ui'), {recursive: true})
await fs.mkdir(outputRoot, {recursive: true})
await fs.writeFile(
path.join(sourceRoot, 'ui/Safe_Stroke2_Corner0_Rounded.svg'),
'<svg viewBox="0 0 24 24"><path d="M0 0h2v2Z"/></svg>',
)
await fs.writeFile(
path.join(scanRoot, 'consumer.ts'),
"import {Safe_Stroke2_Corner0_Rounded} from '#/components/icons/../../../outside'\n",
)
await assert.rejects(
buildIconSet({outputRoot, scanRoot, sourceRoot}),
/invalid icon module path/,
)
})
test('generates deprecated compatibility aliases and detects stale output', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'icon-codegen-'))
t.after(() => fs.rm(root, {recursive: true, force: true}))
const sourceRoot = path.join(root, 'assets/icons')
const scanRoot = path.join(root, 'src')
const outputRoot = path.join(scanRoot, 'components/icons')
await fs.mkdir(path.join(sourceRoot, 'ui'), {recursive: true})
await fs.mkdir(outputRoot, {recursive: true})
const svg = name => `<svg viewBox="0 0 24 24"><path d="M0 0h${name.length}v2Z"/></svg>`
await fs.writeFile(
path.join(sourceRoot, 'ui/CircleCheck_Stroke2_Corner0_Rounded.svg'),
svg('check'),
)
await fs.writeFile(
path.join(sourceRoot, 'ui/CirclePlus_Stroke2_Corner0_Rounded.svg'),
svg('plus'),
)
await fs.writeFile(
path.join(sourceRoot, 'ui/EnvelopeOpen_Stroke2_Corner0_Rounded.svg'),
svg('envelope'),
)
await fs.writeFile(
path.join(scanRoot, 'consumer.ts'),
"import {unrelated} from '#/somewhere-else'\nimport {CircleCheck_Stroke2_Corner0_Rounded} from '#/components/icons/CircleCheck'\nimport {Envelope_Open_Stoke2_Corner0_Rounded} from '#/components/icons/EnvelopeOpen'\n",
)
const result = await buildIconSet({outputRoot, scanRoot, sourceRoot})
assert.match(result.tsOutputs.get('CircleCheck.tsx'), /@deprecated Import CircleCheck_Stroke2_Corner0_Rounded from `#\/components\/icons\/Circle`/)
assert.match(result.tsOutputs.get('EnvelopeOpen.tsx'), /export const Envelope_Open_Stoke2_Corner0_Rounded =/)
assert.doesNotMatch(
result.tsOutputs.get('EnvelopeOpen.tsx'),
/from ['"]\.\/EnvelopeOpen['"]/,
)
const written = await applyIconSet({check: false, outputRoot, result, sourceRoot})
assert.ok(written.includes(path.relative(process.cwd(), path.join(outputRoot, 'Circle.tsx'))))
assert.ok(written.includes(path.relative(process.cwd(), path.join(outputRoot, 'CircleCheck.tsx'))))
assert.deepEqual(await applyIconSet({check: true, outputRoot, result, sourceRoot}), [])
await fs.appendFile(path.join(outputRoot, 'Circle.tsx'), '// stale\n')
assert.deepEqual(await applyIconSet({check: true, outputRoot, result, sourceRoot}), [
path.relative(process.cwd(), path.join(outputRoot, 'Circle.tsx')),
])
})
test('refuses to overwrite handwritten canonical modules', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'icon-codegen-'))
t.after(() => fs.rm(root, {recursive: true, force: true}))
const sourceRoot = path.join(root, 'assets/icons')
const scanRoot = path.join(root, 'src')
const outputRoot = path.join(scanRoot, 'components/icons')
await fs.mkdir(path.join(sourceRoot, 'ui'), {recursive: true})
await fs.mkdir(outputRoot, {recursive: true})
await fs.writeFile(
path.join(sourceRoot, 'ui/Logo_Stroke2_Corner0_Rounded.svg'),
'<svg viewBox="0 0 24 24"><path d="M0 0h2v2Z"/></svg>',
)
await fs.writeFile(
path.join(outputRoot, 'Logo.tsx'),
"import {createSinglePathSVG} from './TEMPLATE'\nexport const Logo = createSinglePathSVG({path: 'M0 0', viewBox: '0 0 24 24'})\nexport const Full = () => null\n",
)
await assert.rejects(
buildIconSet({outputRoot, scanRoot, sourceRoot}),
/generated output would overwrite a handwritten module/,
)
})