Files
2026-02-15 17:09:23 +08:00

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));