kitten-icons
Version:
A subset of the Phosphor icon set in Kitten component format.
335 lines (278 loc) • 10.7 kB
JavaScript
/**
Generate Kitten components from Phosphor icons, organised alphabetically.
This is as much for findablity as it is because of (undocumented?)
4MB source file size limit in the TypeScript language server that
ignores exports in files larger than this size unless the file is
open in the current workspace.
Two indices – categories and tags – are also created based
on the metadata from the library.
Copyright © 2025-present Aral Balkan, Small Technology Foundation.
Released under AGPL v3.0.
*/
import fs from 'node:fs'
import path from 'node:path'
import { icons as phosphorIcons } from '@phosphor-icons/core'
const phosphorPackage = JSON.parse (fs.readFileSync(path.join(process.cwd(), 'node_modules', '@phosphor-icons', 'core', 'package.json'), 'utf-8'))
const phosphorVersion = phosphorPackage.version
const weights = ['thin', 'light', 'regular', 'bold', 'fill', 'duotone']
const hasAmpersandRegExp = /.*? & (.).*?/
const toCamelCase = (stringWithSpaces) => {
return stringWithSpaces
.split(' ')
.map((word, index) => {
if (index === 0) {
return word.toLowerCase()
}
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
})
.join('')
}
const assetsFolder = path.join(process.cwd(), 'node_modules/@phosphor-icons/core/assets')
// We exclude the many Big Tech/surveillance capitalist/people farmer logos by
// default and only include the ones that are non-commercial/community based
// that align with the Small Technology principles.
const logosToInclude = [
'linux-logo', 'markdown-logo', 'mastodon-logo',
'webhooks-logo', 'fediverse-logo'
]
// These are icons we don’t want in our set because they go against
// the Small Technology principles. e.g., Bitcoin due to environmental
// impact of proof-of-work.
const iconsToExclude = [
'currency-btc', 'bug-droid', 'article-medium', 'article-ny-times'
]
let numberOfIconsInKitten = 0
const memoryBefore = process.memoryUsage().heapUsed
const numberOfIconsInOriginalLibrary = phosphorIcons.length
const indexKeys = {
categories: new Set(),
tags: new Set()
}
const taxonomies = {
categories: {},
tags: {},
}
const catalog = []
// Recreate the catalog folder so we start with a fresh one.
const catalogFolder = path.join(process.cwd(), 'catalog')
fs.rmSync(catalogFolder, { recursive: true, force: true })
fs.mkdirSync(catalogFolder)
console.time('Icon component generation took')
phosphorIcons.forEach(icon => {
// Skip logos of toxic Silicon Valley corporations.
if ((icon.name.endsWith('-logo') || icon.name.endsWith('-logo-simple')) && !logosToInclude.includes(icon.name)) {
return
}
// Skip any other icons that have been specifically marked to be skipped.
if (iconsToExclude.includes(icon.name)) {
return
}
numberOfIconsInKitten++
const iconWeights = {
thin: {},
light: {},
regular: {},
bold: {},
fill: {},
duotone: {}
}
weights.forEach(weight => {
const suffix = weight === 'regular' ? '' : `-${weight}`
const svg = fs.readFileSync(path.join(assetsFolder, weight, `${icon.name}${suffix}.svg`), 'utf-8')
iconWeights[weight] = svg.replace(/^<svg.*?>/, '').replace('</svg>', '')
})
// Rename user icon (there is already a person icon so we special-case this).
// (We don’t have users in Small Tech. We have people and we treat them as such.)
if (icon.name === 'user') {
icon.name = 'person-close-up'
icon.pascal_name = 'PersonCloseUp'
}
if (icon.name === 'users') {
icon.name = 'people'
icon.pascal_name = 'People'
}
// Rename all other “user-” icons to “person-”
if (icon.name.startsWith('user-')) {
icon.name = icon.name.replace('user-', 'person-')
icon.pascal_name = icon.pascal_name.replace('User', 'Person')
}
// Ditto for “users-”
if (icon.name.startsWith('users-')) {
icon.name = icon.name.replace('users-', 'people-')
icon.pascal_name = icon.pascal_name.replace('User', 'People')
}
// Also add a camelCase name.
icon.camel_case_name = icon.pascal_name.charAt(0).toLowerCase() + icon.pascal_name.slice(1)
let iconWeightsLines = JSON.stringify(iconWeights, null, 2).split('\n')
iconWeightsLines.splice(0, 1)
iconWeightsLines.splice(iconWeightsLines.length-1, 1)
iconWeightsLines = iconWeightsLines.map(line => ` ${line}`)
const iconWeightsString = `{\n${iconWeightsLines.join('\n')}\n }`
const code = ` ${icon.camel_case_name}: function ({ alt = '', colour = 'currentColor', weight = 'regular', size = '1em', mirror = false, SLOT='', ...otherProps} = {}) {
const iconName = '${icon.name.replaceAll('-', ' ')}'
const iconWeights = ${iconWeightsString}
return globalThis.kitten.html\`<svg viewBox="0 0 256 256" fill="\${colour}" width="\${size}" height="\${size}" style="\${mirror ? 'transform: scale(-1, 1);' : null}" alt="\${alt === '' ? iconName : alt}" ...\${otherProps}>\${SLOT}\${[iconWeights[weight]]}</svg>\`
},
`
catalog.push(code)
// Create categories and tags indices for easier icon
// browsing during authoring.
const namesToExclude = [
'*new*',
'*updated*',
'user',
'users',
'ai',
'ui',
'divider. symbol',
'plus/minus',
'add/subtract',
'on/off',
'giphy',
'pga',
'macbook',
'sonyPlaystation',
'microsoftXbox',
'nintendoSwitch',
'mulitplication',
'skype',
'discord',
'facetime',
'differently abled', // e.g., see https://www.irishtimes.com/life-and-style/health-family/disabled-is-not-a-bad-word-stop-telling-people-with-disabilities-it-is-1.4857377
]
const namesToSubstitute = {
'lgbtq+': 'lgbtq', // + is not valid in property names
'c++': 'cPlusPlus',
'c#': 'cSharp',
'.md': 'md',
'.gif': 'gif',
'2d': 'twoDimensional',
'3d': 'threeDimensional',
'12': 'twelve',
'360': 'threeSixty',
'404': 'fourOhFour',
'70s': 'seventies',
'internationalization': 'internationalisation',
'colors': 'colours',
'colorPicker': 'colourPicker',
'copr.' : 'copr',
'cancel. unavailable' : 'cancel',
'new': 'newThing' // TypeScript language server has issues with bracket matching when new is used as object key (even though it’s perfectly valid to do so).
}
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const addToIndex = (index, icon) => {
icon[index].forEach(name => {
// Ignore names that are not valid property names in JavaScript.
if (namesToExclude.includes(name)) return
// Ignore single-glyph names (these are usually symbols, which
// are illegal as property names).
if (Array.from(graphemeSegmenter.segment(name)).length === 1) return
// Substitute names.
if (namesToSubstitute[name] !== undefined) {
name = namesToSubstitute[name]
}
// Convert ‘x & y’ style item names to xAndY.
const ampersandMatch = name.match(hasAmpersandRegExp)
if (ampersandMatch !== null) {
name = name.replace(` & ${ampersandMatch[1]}`, `And${ampersandMatch[1].toUpperCase()}`)
}
if (name.match(' ') !== null) {
name =toCamelCase(name)
}
// Remove other illegal characters that exist in tags.
name = name
.replaceAll('\'', '')
.replaceAll('-', '')
indexKeys[index].add(name)
// Add to taxononmy index. We’ll generate source from this later.
if (taxonomies[index][name] === undefined) {
// Using a set here as some tags, e.g, to-do and todo may reduce
// down to the same name and thus result in duplicates, otherwise.
taxonomies[index][name] = new Set()
}
taxonomies[index][name].add(icon.camel_case_name)
})
}
addToIndex('categories', icon)
addToIndex('tags', icon)
})
// Write catalog.
const code = `/**
Kitten Icon Catalog
« This is a generated file. »
*/
// @ts-check
export const catalog = {
${catalog.join('')}}
`
fs.writeFileSync(path.join(catalogFolder, 'index.mjs'), code, 'utf-8')
const taxonomySource = taxonomy => `/**
Kitten Icons – taxonomy: ${taxonomy}
« This is a generated file. »
*/
// @ts-check
import { catalog } from './index.mjs'
export const ${taxonomy} = {
${Object.entries(taxonomies[taxonomy]).map((/** @type {[string, Set<string>]} array */ [name, components]) => ` ${name}: {
${Array.from(components).map(component => ` ${component}:catalog.${component}`).join(`,\n`)}
}`).join(`,\n`)}
}
`
const typesSource = `
/**
Kitten icons - type information.
« This is a generated file. »
*/
// Improve intellisense.
// Courtesy of: MHebes and jcalz
// (https://stackoverflow.com/a/69288824)
export type Expand<T> = T extends (...args: infer A) => infer R
? (...args: Expand<A>) => Expand<R>
: T extends infer O
? { [K in keyof O]: O[K] }
: never;
type taggedTemplate = (
strings: TemplateStringsArray,
...properties: any[]
) => string | string[] | Promise<string | string[]>
type Icon = Expand<(props?: {
alt?: string
colour?: string
weight?: string
size?: string
mirror?: boolean
SLOT?: taggedTemplate
[key: string]: any
}) => taggedTemplate>
export type Icons = {
${Object.values(catalog).map(name => `${name.split(':')[0]}:Icon`).join(',')},
categories: {
${
Object.entries(taxonomies['categories']).map(
(/** @type {[string, Set<string>]} array */ [name, components]) => `${name}:{${Array.from(components).map(component => `${component}:Icon`).join(`,`)}}`
)
}
},
tags: {
${
Object.entries(taxonomies['tags']).map(
(/** @type {[string, Set<string>]} array */ [name, components]) => `${name}:{${Array.from(components).map(component => `${component}:Icon`).join(`,`)}}`
)
}
}
}
export type KittenIcons = Expand<Icons>
`
fs.writeFileSync(path.join(process.cwd(), 'types.d.ts'), typesSource, 'utf-8')
const categoriesSource = taxonomySource('categories')
const tagsSource = taxonomySource('tags')
fs.writeFileSync(path.join(catalogFolder, 'categories.mjs'), categoriesSource, 'utf-8')
fs.writeFileSync(path.join(catalogFolder, 'tags.mjs'), tagsSource, 'utf-8')
console.timeEnd('Icon component generation took')
console.info('Number of icons (original library)', numberOfIconsInOriginalLibrary)
console.info('Number of icons (included in Kitten)', numberOfIconsInKitten)
console.info('Number of icons removed from original library', numberOfIconsInOriginalLibrary - numberOfIconsInKitten)
console.info('Number of tags', indexKeys.tags.size)
// Calculate memory usage
const memoryAfter = process.memoryUsage().heapUsed
console.log('Icon components memory usage:', memoryAfter - memoryBefore, 'bytes')