Skip to content

Commit 1f21d66

Browse files
authored
devex: add icon cmd (#4958)
* feat: icons add cmd * fix: dep * Update packages/assets/build/add-icons.ts Signed-off-by: Calum H. <[email protected]> * fix: lint --------- Signed-off-by: Calum H. <[email protected]> Signed-off-by: Calum H. <[email protected]>
1 parent 3adee66 commit 1f21d66

File tree

4 files changed

+229
-1
lines changed

4 files changed

+229
-1
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"prepr:frontend": "turbo run prepr --filter=@modrinth/frontend --filter=@modrinth/app-frontend",
2121
"prepr:frontend:lib": "turbo run prepr --filter=@modrinth/ui --filter=@modrinth/moderation --filter=@modrinth/assets --filter=@modrinth/blog --filter=@modrinth/api-client --filter=@modrinth/utils --filter=@modrinth/tooling-config",
2222
"prepr:frontend:web": "turbo run prepr --filter=@modrinth/frontend",
23-
"prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend"
23+
"prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend",
24+
"icons:add": "pnpm --filter @modrinth/assets icons:add"
2425
},
2526
"devDependencies": {
2627
"@modrinth/tooling-config": "workspace:*",

packages/assets/build/add-icons.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import readline from 'node:readline'
4+
5+
const packageRoot = path.resolve(__dirname, '..')
6+
const iconsDir = path.join(packageRoot, 'icons')
7+
const lucideIconsDir = path.join(packageRoot, 'node_modules/lucide-static/icons')
8+
9+
function listAvailableIcons(): string[] {
10+
if (!fs.existsSync(lucideIconsDir)) {
11+
return []
12+
}
13+
return fs
14+
.readdirSync(lucideIconsDir)
15+
.filter((file) => file.endsWith('.svg'))
16+
.map((file) => path.basename(file, '.svg'))
17+
.sort()
18+
}
19+
20+
function paginateList(allIcons: string[], pageSize = 20): void {
21+
let page = 0
22+
let search = ''
23+
let filteredIcons = allIcons
24+
25+
const getFilteredIcons = (): string[] => {
26+
if (!search) return allIcons
27+
return allIcons.filter((icon) => icon.includes(search))
28+
}
29+
30+
const renderPage = (): void => {
31+
console.clear()
32+
filteredIcons = getFilteredIcons()
33+
const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize))
34+
35+
if (page >= totalPages) page = Math.max(0, totalPages - 1)
36+
37+
const start = page * pageSize
38+
const end = Math.min(start + pageSize, filteredIcons.length)
39+
const pageIcons = filteredIcons.slice(start, end)
40+
41+
console.log(`\x1b[1mAvailable Lucide Icons\x1b[0m`)
42+
console.log(`\x1b[2mSearch: \x1b[0m${search || '\x1b[2m(type to search)\x1b[0m'}\n`)
43+
44+
if (pageIcons.length === 0) {
45+
console.log(` \x1b[2mNo icons found matching "${search}"\x1b[0m`)
46+
} else {
47+
pageIcons.forEach((icon) => {
48+
if (search) {
49+
const highlighted = icon.replace(search, `\x1b[33m${search}\x1b[0m`)
50+
console.log(` ${highlighted}`)
51+
} else {
52+
console.log(` ${icon}`)
53+
}
54+
})
55+
}
56+
57+
console.log(
58+
`\n\x1b[2m${filteredIcons.length}/${allIcons.length} icons | Page ${page + 1}/${totalPages} | ← → navigate | :q quit\x1b[0m`,
59+
)
60+
}
61+
62+
renderPage()
63+
64+
readline.emitKeypressEvents(process.stdin)
65+
if (process.stdin.isTTY) {
66+
process.stdin.setRawMode(true)
67+
}
68+
69+
process.stdin.on('keypress', (str, key) => {
70+
if (key.ctrl && key.name === 'c') {
71+
console.clear()
72+
process.exit(0)
73+
}
74+
75+
// :q to quit
76+
if (search === ':' && key.name === 'q') {
77+
console.clear()
78+
process.exit(0)
79+
}
80+
81+
// Navigation
82+
if (key.name === 'right') {
83+
const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize))
84+
if (page < totalPages - 1) {
85+
page++
86+
renderPage()
87+
}
88+
return
89+
}
90+
if (key.name === 'left') {
91+
if (page > 0) {
92+
page--
93+
renderPage()
94+
}
95+
return
96+
}
97+
98+
// Backspace
99+
if (key.name === 'backspace') {
100+
search = search.slice(0, -1)
101+
page = 0
102+
renderPage()
103+
return
104+
}
105+
106+
// Escape to clear search
107+
if (key.name === 'escape') {
108+
search = ''
109+
page = 0
110+
renderPage()
111+
return
112+
}
113+
114+
// Type to search
115+
if (str && str.length === 1 && !key.ctrl && !key.meta) {
116+
search += str
117+
page = 0
118+
renderPage()
119+
}
120+
})
121+
}
122+
123+
function addIcon(iconId: string, overwrite: boolean): boolean {
124+
const sourcePath = path.join(lucideIconsDir, `${iconId}.svg`)
125+
const targetPath = path.join(iconsDir, `${iconId}.svg`)
126+
127+
if (!fs.existsSync(sourcePath)) {
128+
console.error(`❌ Icon "${iconId}" not found in lucide-static`)
129+
console.error(` Run with --list to see available icons`)
130+
return false
131+
}
132+
133+
if (fs.existsSync(targetPath) && !overwrite) {
134+
console.log(`⏭️ Skipping "${iconId}" (already exists, use --overwrite to replace)`)
135+
return false
136+
}
137+
138+
fs.copyFileSync(sourcePath, targetPath)
139+
console.log(`✅ Added "${iconId}"`)
140+
return true
141+
}
142+
143+
function main(): void {
144+
const args = process.argv.slice(2)
145+
146+
if (args.includes('--help') || args.includes('-h')) {
147+
console.log(`
148+
Usage: pnpm icons:add [options] <icon_id> [icon_id...]
149+
150+
Options:
151+
--list, -l Browse all available Lucide icons (interactive)
152+
--overwrite, -o Overwrite existing icons
153+
--help, -h Show this help message
154+
155+
Examples:
156+
pnpm icons:add heart star settings-2
157+
pnpm icons:add --overwrite heart
158+
pnpm icons:add --list # Interactive browser
159+
pnpm icons:add --list | grep arrow # Pipe to grep
160+
161+
Interactive controls:
162+
Type Search icons
163+
← → Navigate pages
164+
Escape Clear search
165+
:q Quit
166+
`)
167+
process.exit(0)
168+
}
169+
170+
if (args.includes('--list') || args.includes('-l')) {
171+
const icons = listAvailableIcons()
172+
if (icons.length === 0) {
173+
console.error('❌ lucide-static not installed. Run pnpm install first.')
174+
process.exit(1)
175+
}
176+
if (process.stdout.isTTY) {
177+
paginateList(icons)
178+
} else {
179+
// Non-interactive mode (piped output)
180+
icons.forEach((icon) => console.log(icon))
181+
process.exit(0)
182+
}
183+
return
184+
}
185+
186+
const overwrite = args.includes('--overwrite') || args.includes('-o')
187+
const iconIds = args.filter((arg) => !arg.startsWith('-'))
188+
189+
if (iconIds.length === 0) {
190+
console.error('Usage: pnpm icons:add <icon_id> [icon_id...]')
191+
console.error('Example: pnpm icons:add heart star settings-2')
192+
console.error('Run with --help for more options')
193+
process.exit(1)
194+
}
195+
196+
if (!fs.existsSync(lucideIconsDir)) {
197+
console.error('❌ lucide-static not installed. Run pnpm install first.')
198+
process.exit(1)
199+
}
200+
201+
let added = 0
202+
for (const iconId of iconIds) {
203+
if (addIcon(iconId, overwrite)) {
204+
added++
205+
}
206+
}
207+
208+
if (added > 0) {
209+
console.log(`\n📦 Added ${added} icon(s). Run 'pnpm prepr:frontend:lib' to update exports.`)
210+
}
211+
}
212+
213+
main()

packages/assets/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
"scripts": {
88
"lint": "pnpm run icons:validate && eslint . && prettier --check .",
99
"fix": "pnpm run icons:generate && eslint . --fix && prettier --write .",
10+
"icons:add": "jiti build/add-icons.ts",
1011
"icons:test": "jiti build/generate-exports.ts --test",
1112
"icons:validate": "jiti build/generate-exports.ts --validate",
1213
"icons:generate": "jiti build/generate-exports.ts"
1314
},
1415
"devDependencies": {
1516
"@modrinth/tooling-config": "workspace:*",
17+
"@types/node": "^20.1.0",
1618
"jiti": "^2.4.2",
19+
"lucide-static": "^0.562.0",
1720
"vue": "^3.5.13"
1821
}
1922
}

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)