#!/usr/bin/env node const path = require('node:path'); const fs = require('node:fs'); const vm = require('node:vm'); const http = require('node:http'); const url = require('node:url'); const { parseArgs } = require('node:util'); // ============================================================================ // CONSTANTS // ============================================================================ const CONFIG = { host: '127.0.0.1', port: 12667, paths: { css: path.join(__dirname, 'fonts', 'ficons.css'), webfonts: path.join(__dirname, 'fonts', 'webfonts'), explorer: path.join(__dirname, 'explorer.html'), iconData: path.join(__dirname, 'data', 'all_icons.js') } }; const MIME_TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', }; const COMMANDS = { EXPLORE: 'explore', SEARCH: 'search' }; const EXIT_CODES = { SUCCESS: 0, ERROR: 1 }; // ============================================================================ // ICON DATA LAYER // ============================================================================ class IconLoader { constructor(dataPath) { this.dataPath = dataPath; this._cache = null; } load() { if (this._cache) { return this._cache; } const source = fs.readFileSync(this.dataPath, 'utf8'); const sandbox = { window: {} }; vm.createContext(sandbox); vm.runInContext(source, sandbox); const icons = sandbox.window.FICONS_DATA; if (!Array.isArray(icons)) { throw new Error('Icon data was not loaded correctly.'); } this._cache = icons; return icons; } getPrefixes() { return new Set(this.load().map(icon => icon.prefix)); } } class IconSearcher { constructor(icons) { this.icons = icons; } search(query, variation = null) { const terms = this._parseQuery(query); return this.icons.filter(icon => this._matches(icon, terms) && this._matchesVariation(icon, variation) ); } _parseQuery(query) { return query.toLowerCase().split(/\s+/).filter(Boolean); } _matches(icon, terms) { const haystack = `${icon.name} ${icon.tags || ''}`.toLowerCase(); return terms.every(term => haystack.includes(term)); } _matchesVariation(icon, variation) { return !variation || icon.prefix === variation; } } class ResultFormatter { static formatJson(matches) { const grouped = this._groupByIconName(matches); if (grouped.size === 0) { return '{}'; } const entries = Array.from(grouped.entries()) .map(([name, variations]) => { const sortedVariations = Array.from(variations).sort(); const variationsJson = JSON.stringify(sortedVariations); return ` ${JSON.stringify(name)}: ${variationsJson}`; }); return `{\n${entries.join(',\n')}\n}`; } static _groupByIconName(matches) { const grouped = new Map(); for (const icon of matches) { if (!grouped.has(icon.name)) { grouped.set(icon.name, new Set()); } grouped.get(icon.name).add(icon.prefix); } return grouped; } } // ============================================================================ // HTTP SERVER LAYER // ============================================================================ class FileServer { constructor(rootDir) { this.rootDir = rootDir; } serve(req, res) { const requestPath = this._sanitizePath(req.url); const fullPath = path.join(this.rootDir, requestPath); this._serveFile(fullPath, req, res); } _sanitizePath(urlPath) { const parsed = url.parse(urlPath); let pathname = parsed.pathname; if (pathname === '/') { return '/explorer.html'; } return pathname; } _serveFile(filepath, req, res) { fs.stat(filepath, (err, stats) => { if (err || !stats.isFile()) { this._send404(res); return; } this._sendFile(filepath, res); }); } _sendFile(filepath, res) { fs.readFile(filepath, (err, data) => { if (err) { this._send404(res); return; } const mimeType = this._getMimeType(filepath); res.writeHead(200, { 'Content-Type': mimeType, 'Cache-Control': 'no-cache' }); res.end(data); }); } _send404(res) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } _getMimeType(filepath) { const ext = path.extname(filepath).toLowerCase(); return MIME_TYPES[ext] || 'application/octet-stream'; } } class ExplorerServer { constructor(config) { this.config = config; this.fileServer = new FileServer(__dirname); this.server = null; } start() { this.server = http.createServer((req, res) => { this.fileServer.serve(req, res); }); this.server.listen(this.config.port, this.config.host, () => { console.log(`Ficons Explorer running at http://${this.config.host}:${this.config.port}/`); console.log('Press Ctrl+C to stop the server'); }); this.server.on('error', (err) => this._handleError(err)); } _handleError(err) { if (err.code === 'EADDRINUSE') { console.error(`Error: Port ${this.config.port} is already in use.`); } else { console.error('Server error:', err.message); } process.exit(EXIT_CODES.ERROR); } } // ============================================================================ // CLI LAYER // ============================================================================ class CliParser { static parse(args) { if (args.length === 0) { return { error: 'No command provided' }; } const { values, positionals } = parseArgs({ args, options: { help: { type: 'boolean', short: 'h' }, variation: { type: 'string', short: 'v' }, }, allowPositionals: true, }); return { help: values.help, command: positionals[0]?.toLowerCase(), args: positionals.slice(1), variation: values.variation }; } } class SearchCommand { constructor(iconLoader) { this.iconLoader = iconLoader; } execute(positionals, variation) { const { query, variation: detectedVariation } = this._parseInput(positionals, variation); if (!query) { console.error('Error: search command requires a query.'); console.log('Usage: npx @invisi/ficons search [options]'); process.exit(EXIT_CODES.ERROR); } const icons = this.iconLoader.load(); const searcher = new IconSearcher(icons); const matches = searcher.search(query, detectedVariation); console.log(ResultFormatter.formatJson(matches)); } _parseInput(positionals, variation) { let effectiveVariation = variation; // Auto-detect variation from last positional arg if (!effectiveVariation && positionals.length > 1) { const prefixes = this.iconLoader.getPrefixes(); const lastArg = positionals[positionals.length - 1].toLowerCase(); if (prefixes.has(lastArg)) { effectiveVariation = lastArg; positionals.pop(); } } return { query: positionals.join(' ').trim(), variation: effectiveVariation }; } } class HelpPrinter { static print() { console.log('Usage: npx @invisi/ficons [options]'); console.log(''); console.log('Commands:'); console.log(' explore Start local HTTP server and open icon explorer'); console.log(' search Search for icons by name or tags'); console.log(''); console.log('Options:'); console.log(' -v, --variation Filter by style variation (e.g., rs, rr, bs)'); console.log(' -h, --help Show this help message'); console.log(''); console.log('Examples:'); console.log(' npx @invisi/ficons explore'); console.log(' npx @invisi/ficons search camera'); console.log(' npx @invisi/ficons search arrow rr'); console.log(' npx @invisi/ficons search home --variation rs'); } } class CliApplication { constructor() { this.iconLoader = new IconLoader(CONFIG.paths.iconData); this.searchCommand = new SearchCommand(this.iconLoader); } run(args) { const parsed = CliParser.parse(args); if (parsed.error || parsed.help) { HelpPrinter.print(); process.exit(parsed.error ? EXIT_CODES.ERROR : EXIT_CODES.SUCCESS); } switch (parsed.command) { case COMMANDS.EXPLORE: new ExplorerServer(CONFIG).start(); break; case COMMANDS.SEARCH: this.searchCommand.execute(parsed.args, parsed.variation); break; default: console.error(`Unknown command: ${parsed.command}`); console.log('Run "npx @invisi/ficons --help" for usage information.'); process.exit(EXIT_CODES.ERROR); } } } // ============================================================================ // ENTRY POINT // ============================================================================ const app = new CliApplication(); app.run(process.argv.slice(2));