add icon codegen
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user