c1e56f0c20
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
9.0 KiB
JavaScript
Executable File
355 lines
9.0 KiB
JavaScript
Executable File
#!/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 <query> [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 <command> [options]');
|
|
console.log('');
|
|
console.log('Commands:');
|
|
console.log(' explore Start local HTTP server and open icon explorer');
|
|
console.log(' search <query> Search for icons by name or tags');
|
|
console.log('');
|
|
console.log('Options:');
|
|
console.log(' -v, --variation <prefix> 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));
|