diff --git a/package.json b/package.json index 6ae915412d..94b2206a69 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "prepr:frontend": "turbo run prepr --filter=@modrinth/frontend --filter=@modrinth/app-frontend", "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", "prepr:frontend:web": "turbo run prepr --filter=@modrinth/frontend", - "prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend" + "prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend", + "icons:add": "pnpm --filter @modrinth/assets icons:add" }, "devDependencies": { "@modrinth/tooling-config": "workspace:*", diff --git a/packages/assets/build/add-icons.ts b/packages/assets/build/add-icons.ts new file mode 100644 index 0000000000..ed54805571 --- /dev/null +++ b/packages/assets/build/add-icons.ts @@ -0,0 +1,213 @@ +import fs from 'node:fs' +import path from 'node:path' +import readline from 'node:readline' + +const packageRoot = path.resolve(__dirname, '..') +const iconsDir = path.join(packageRoot, 'icons') +const lucideIconsDir = path.join(packageRoot, 'node_modules/lucide-static/icons') + +function listAvailableIcons(): string[] { + if (!fs.existsSync(lucideIconsDir)) { + return [] + } + return fs + .readdirSync(lucideIconsDir) + .filter((file) => file.endsWith('.svg')) + .map((file) => path.basename(file, '.svg')) + .sort() +} + +function paginateList(allIcons: string[], pageSize = 20): void { + let page = 0 + let search = '' + let filteredIcons = allIcons + + const getFilteredIcons = (): string[] => { + if (!search) return allIcons + return allIcons.filter((icon) => icon.includes(search)) + } + + const renderPage = (): void => { + console.clear() + filteredIcons = getFilteredIcons() + const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize)) + + if (page >= totalPages) page = Math.max(0, totalPages - 1) + + const start = page * pageSize + const end = Math.min(start + pageSize, filteredIcons.length) + const pageIcons = filteredIcons.slice(start, end) + + console.log(`\x1b[1mAvailable Lucide Icons\x1b[0m`) + console.log(`\x1b[2mSearch: \x1b[0m${search || '\x1b[2m(type to search)\x1b[0m'}\n`) + + if (pageIcons.length === 0) { + console.log(` \x1b[2mNo icons found matching "${search}"\x1b[0m`) + } else { + pageIcons.forEach((icon) => { + if (search) { + const highlighted = icon.replace(search, `\x1b[33m${search}\x1b[0m`) + console.log(` ${highlighted}`) + } else { + console.log(` ${icon}`) + } + }) + } + + console.log( + `\n\x1b[2m${filteredIcons.length}/${allIcons.length} icons | Page ${page + 1}/${totalPages} | ← → navigate | :q quit\x1b[0m`, + ) + } + + renderPage() + + readline.emitKeypressEvents(process.stdin) + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + + process.stdin.on('keypress', (str, key) => { + if (key.ctrl && key.name === 'c') { + console.clear() + process.exit(0) + } + + // :q to quit + if (search === ':' && key.name === 'q') { + console.clear() + process.exit(0) + } + + // Navigation + if (key.name === 'right') { + const totalPages = Math.max(1, Math.ceil(filteredIcons.length / pageSize)) + if (page < totalPages - 1) { + page++ + renderPage() + } + return + } + if (key.name === 'left') { + if (page > 0) { + page-- + renderPage() + } + return + } + + // Backspace + if (key.name === 'backspace') { + search = search.slice(0, -1) + page = 0 + renderPage() + return + } + + // Escape to clear search + if (key.name === 'escape') { + search = '' + page = 0 + renderPage() + return + } + + // Type to search + if (str && str.length === 1 && !key.ctrl && !key.meta) { + search += str + page = 0 + renderPage() + } + }) +} + +function addIcon(iconId: string, overwrite: boolean): boolean { + const sourcePath = path.join(lucideIconsDir, `${iconId}.svg`) + const targetPath = path.join(iconsDir, `${iconId}.svg`) + + if (!fs.existsSync(sourcePath)) { + console.error(`❌ Icon "${iconId}" not found in lucide-static`) + console.error(` Run with --list to see available icons`) + return false + } + + if (fs.existsSync(targetPath) && !overwrite) { + console.log(`⏭️ Skipping "${iconId}" (already exists, use --overwrite to replace)`) + return false + } + + fs.copyFileSync(sourcePath, targetPath) + console.log(`✅ Added "${iconId}"`) + return true +} + +function main(): void { + const args = process.argv.slice(2) + + if (args.includes('--help') || args.includes('-h')) { + console.log(` +Usage: pnpm icons:add [options] [icon_id...] + +Options: + --list, -l Browse all available Lucide icons (interactive) + --overwrite, -o Overwrite existing icons + --help, -h Show this help message + +Examples: + pnpm icons:add heart star settings-2 + pnpm icons:add --overwrite heart + pnpm icons:add --list # Interactive browser + pnpm icons:add --list | grep arrow # Pipe to grep + +Interactive controls: + Type Search icons + ← → Navigate pages + Escape Clear search + :q Quit +`) + process.exit(0) + } + + if (args.includes('--list') || args.includes('-l')) { + const icons = listAvailableIcons() + if (icons.length === 0) { + console.error('❌ lucide-static not installed. Run pnpm install first.') + process.exit(1) + } + if (process.stdout.isTTY) { + paginateList(icons) + } else { + // Non-interactive mode (piped output) + icons.forEach((icon) => console.log(icon)) + process.exit(0) + } + return + } + + const overwrite = args.includes('--overwrite') || args.includes('-o') + const iconIds = args.filter((arg) => !arg.startsWith('-')) + + if (iconIds.length === 0) { + console.error('Usage: pnpm icons:add [icon_id...]') + console.error('Example: pnpm icons:add heart star settings-2') + console.error('Run with --help for more options') + process.exit(1) + } + + if (!fs.existsSync(lucideIconsDir)) { + console.error('❌ lucide-static not installed. Run pnpm install first.') + process.exit(1) + } + + let added = 0 + for (const iconId of iconIds) { + if (addIcon(iconId, overwrite)) { + added++ + } + } + + if (added > 0) { + console.log(`\n📦 Added ${added} icon(s). Run 'pnpm prepr:frontend:lib' to update exports.`) + } +} + +main() diff --git a/packages/assets/package.json b/packages/assets/package.json index 7d0a65ae61..8c5f12173e 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -7,13 +7,16 @@ "scripts": { "lint": "pnpm run icons:validate && eslint . && prettier --check .", "fix": "pnpm run icons:generate && eslint . --fix && prettier --write .", + "icons:add": "jiti build/add-icons.ts", "icons:test": "jiti build/generate-exports.ts --test", "icons:validate": "jiti build/generate-exports.ts --validate", "icons:generate": "jiti build/generate-exports.ts" }, "devDependencies": { "@modrinth/tooling-config": "workspace:*", + "@types/node": "^20.1.0", "jiti": "^2.4.2", + "lucide-static": "^0.562.0", "vue": "^3.5.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 426871fb39..0a197629fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,9 +428,15 @@ importers: '@modrinth/tooling-config': specifier: workspace:* version: link:../tooling-config + '@types/node': + specifier: ^20.1.0 + version: 20.14.11 jiti: specifier: ^2.4.2 version: 2.4.2 + lucide-static: + specifier: ^0.562.0 + version: 0.562.0 vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.8.3) @@ -5623,6 +5629,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-static@0.562.0: + resolution: {integrity: sha512-TM2vNVOEsO3+ijmno7n/VmxUo0Shr9OXC/UqZc5n4xEVyXX4E4NVvXoRPAZiSsIsdvlQ7alGOcIC/QGtR+OgUQ==} + magic-string-ast@0.6.2: resolution: {integrity: sha512-oN3Bcd7ZVt+0VGEs7402qR/tjgjbM7kPlH/z7ufJnzTLVBzXJITRHOJiwMmmYMgZfdoWQsfQcY+iKlxiBppnMA==} engines: {node: '>=16.14.0'} @@ -14324,6 +14333,8 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-static@0.562.0: {} + magic-string-ast@0.6.2: dependencies: magic-string: 0.30.14