694 lines
26 KiB
TypeScript
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()
|
|
}
|