@nodo-icons/mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +26 -0
- package/README.md +61 -2
- package/data/icons.json +1 -0
- package/data/shadcn-map.json +1 -0
- package/package.json +41 -4
- package/src/cli.js +39 -0
- package/src/core.js +117 -0
- package/src/migrate.js +67 -0
- package/src/render.js +66 -0
- package/src/server.js +95 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"ChevronRightIcon":"ChevronRightIcon","CheckIcon":"CheckIcon","ChevronDownIcon":"ChevronDownIcon","SearchIcon":"SearchIcon","MoreHorizontalIcon":"MoreHorizontalIcon","GalleryVerticalEndIcon":"LayersIcon","PlusIcon":"PlusIcon","BellIcon":"BellIcon","CopyIcon":"CopyIcon","FolderIcon":"FolderIcon","ChevronsUpDownIcon":"ChevronUpDownIcon","CreditCardIcon":"CreditCardIcon","LogOutIcon":"LogOutIcon","FileIcon":"FileIcon","ArrowRightIcon":"ArrowRightIcon","Trash2Icon":"TrashIcon","TrashIcon":"TrashIcon","FileTextIcon":"FileTextIcon","SparklesIcon":"SparklesIcon","Settings2Icon":"SlidersHorizontalIcon","TerminalIcon":"TerminalIcon","ShareIcon":"ShareIcon","SettingsIcon":"SettingsIcon","BadgeCheckIcon":"BadgeCheckIcon","ArrowUpRightIcon":"ArrowUpRightIcon","HomeIcon":"HomeIcon","XIcon":"CloseIcon","InfoIcon":"InfoIcon","UserIcon":"UserIcon","InboxIcon":"InboxIcon","ArrowUpIcon":"ArrowUpIcon","LinkIcon":"LinkIcon","ChevronLeftIcon":"ChevronLeftIcon","MinusIcon":"MinusIcon","Loader2Icon":"LoaderCircleIcon","ArrowDownIcon":"ArrowDownIcon","AudioLinesIcon":"WaveformIcon","CalendarIcon":"CalendarIcon","ScissorsIcon":"ScissorsIcon","ClipboardPasteIcon":"PasteIcon","MailIcon":"MailIcon","SaveIcon":"SaveIcon","TerminalSquareIcon":"TerminalIcon","BotIcon":"RobotIcon","FrameIcon":"ArtboardIcon","PieChartIcon":"ChartPieIcon","MapIcon":"MapIcon","DownloadIcon":"DownloadIcon","BookOpenIcon":"BookOpenIcon","ChevronUpIcon":"ChevronUpIcon","PanelLeftIcon":"SidebarLeftIcon","CircleCheckIcon":"CheckCircleIcon","CircleAlertIcon":"AlertCircleIcon","ArrowLeftIcon":"ArrowLeftIcon","TrendingUpIcon":"TrendingUpIcon","HelpCircleIcon":"HelpIcon","CodeIcon":"CodeIcon","CircleDashedIcon":"CircleDashedIcon","GlobeIcon":"GlobeIcon","RefreshCwIcon":"RefreshIcon","BoldIcon":"BoldIcon","ItalicIcon":"ItalicIcon","UnderlineIcon":"UnderlineIcon","ChartLineIcon":"ChartLineIcon","StarIcon":"StarIcon","FileCodeIcon":"FileCodeIcon","FileSearchIcon":"FileSearchIcon","SendIcon":"SendIcon","TriangleAlertIcon":"AlertTriangleIcon","OctagonXIcon":"CloseOctagonIcon","BluetoothIcon":"BluetoothIcon","HeartIcon":"HeartIcon","ListIcon":"ListBulletIcon","ImageIcon":"ImageIcon","PencilIcon":"EditIcon","ArchiveIcon":"ArchiveIcon","LayoutIcon":"LayoutIcon","UsersIcon":"UsersIcon","TableIcon":"TableIcon","ChartBarIcon":"ChartBarIcon","ShoppingBagIcon":"ShoppingBagIcon","ThumbsUpIcon":"ThumbsUpIcon","ThumbsDownIcon":"ThumbsDownIcon","SunIcon":"SunIcon","KeyboardIcon":"KeyboardIcon","FileArchiveIcon":"FileZipIcon","PresentationIcon":"PresentationIcon","ClockIcon":"ClockIcon","BookmarkIcon":"BookmarkIcon","LifeBuoyIcon":"LifebuoyIcon","BlocksIcon":"BlocksIcon","MessageCircleQuestionIcon":"ChatQuestionIcon","StarOffIcon":"StarOffIcon","ArrowLeftCircleIcon":"ArrowCircleLeftIcon","BadgeCheck":"BadgeCheckIcon","VolumeX":"VolumeMuteIcon","AlertTriangleIcon":"AlertTriangleIcon","UserRoundXIcon":"UserXIcon","FlipHorizontalIcon":"FlipHorizontalIcon","FlipVerticalIcon":"FlipVerticalIcon","RotateCwIcon":"RotateCwIcon","Clock2Icon":"ClockIcon","CaptionsIcon":"ClosedCaptionsIcon","MinimizeIcon":"MinusIcon","MaximizeIcon":"PlusIcon","SmileIcon":"SmileIcon","CalculatorIcon":"CalculatorIcon","FolderPlusIcon":"FolderPlusIcon","LayoutGridIcon":"ViewGridIcon","ZoomInIcon":"ZoomInIcon","ZoomOutIcon":"ZoomOutIcon","ActivityIcon":"PulseIcon","MessageSquareIcon":"ChatSquareIcon","WalletIcon":"WalletIcon","Building2Icon":"OfficeIcon","PlusCircleIcon":"PlusCircleIcon","ChartPieIcon":"ChartPieIcon","EyeOffIcon":"EyeOffIcon","MicIcon":"MicrophoneIcon","RadioIcon":"RadioIcon","ExternalLinkIcon":"ExternalLinkIcon","ShoppingCartIcon":"CartIcon","LifeBuoy":"LifebuoyIcon","Send":"SendIcon","AppWindowIcon":"WindowIcon","BookOpen":"BookOpenIcon","StopCircleIcon":"StopCircleIcon","MoreVerticalIcon":"MoreVerticalIcon","FolderOpenIcon":"FolderOpenIcon","FolderSearchIcon":"FolderSearchIcon","EyeIcon":"EyeIcon","PaletteIcon":"PaletteIcon","MoonIcon":"MoonIcon","MonitorIcon":"MonitorIcon","LanguagesIcon":"TranslateIcon","ShieldIcon":"ShieldIcon","FileWarningIcon":"FileWarningIcon","GitBranchIcon":"GitBranchIcon","CircleUserIcon":"UserCircleIcon","RefreshCcwIcon":"RefreshCcwIcon","WrapTextIcon":"TextWrapIcon","BrainIcon":"BrainIcon","LayoutDashboardIcon":"LayoutDashboardIcon","CameraIcon":"CameraIcon","CircleHelpIcon":"HelpIcon","DatabaseIcon":"DatabaseIcon","FileChartColumnIcon":"FileChartIcon","CommandIcon":"CommandIcon","GripVerticalIcon":"GripVerticalIcon","LoaderIcon":"LoaderIcon","EllipsisVerticalIcon":"MoreVerticalIcon","Columns3Icon":"LayoutColumnsThreeIcon","ChevronsLeftIcon":"ChevronDoubleLeftIcon","ChevronsRightIcon":"ChevronDoubleRightIcon","CirclePlusIcon":"PlusCircleIcon","CircleUserRoundIcon":"UserCircleIcon","TrendingDownIcon":"TrendingDownIcon","ArchiveXIcon":"ArchiveXIcon","CornerUpRightIcon":"CornerUpRightIcon","CornerUpLeftIcon":"CornerUpLeftIcon","MenuIcon":"MenuIcon","PaintbrushIcon":"PaintbrushIcon","MessageCircleIcon":"ChatIcon","VideoIcon":"VideoIcon","LockIcon":"LockIcon","VolumeOffIcon":"VolumeMuteIcon"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodo-icons/mcp",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"author": "Nodo Icons <hello@nodoicons.com> (https://nodoicons.com)",
|
|
4
|
+
"bugs": {
|
|
5
|
+
"email": "support@nodoicons.com",
|
|
6
|
+
"url": "https://nodoicons.com/docs/"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/NodoIcons/nodoicons.git"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.1.0",
|
|
13
|
+
"description": "MCP server and CLI for Nodo Icons: let AI agents search 5,000 icons and insert SVG, React or Vue code.",
|
|
14
|
+
"mcpName": "io.github.NodoIcons/nodo-icons",
|
|
15
|
+
"homepage": "https://nodoicons.com/docs/",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"bin": {
|
|
19
|
+
"nodo-icons": "src/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/",
|
|
23
|
+
"data/",
|
|
24
|
+
"LICENSE.md",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"icons",
|
|
32
|
+
"icon-set",
|
|
33
|
+
"icon-library",
|
|
34
|
+
"svg",
|
|
35
|
+
"ui",
|
|
36
|
+
"ui-icons",
|
|
37
|
+
"nodo-icons",
|
|
38
|
+
"mcp",
|
|
39
|
+
"model-context-protocol",
|
|
40
|
+
"ai",
|
|
41
|
+
"cli"
|
|
42
|
+
]
|
|
43
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nodo-icons — no arguments: start the MCP server (stdio). With a command: a small CLI.
|
|
3
|
+
// nodo-icons search <words> [--limit 20] [--category name]
|
|
4
|
+
// nodo-icons get <name> [--style linear] [--format svg|react|vue|sprite] [--size 24] [--color #000]
|
|
5
|
+
// nodo-icons migrate-lucide [dir] [--dry] switch lucide-react imports to @nodo-icons/react
|
|
6
|
+
// nodo-icons styles | categories
|
|
7
|
+
import { search, getIcon, styles, categories } from './core.js';
|
|
8
|
+
|
|
9
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
10
|
+
const flags = {}, words = [];
|
|
11
|
+
for (let i = 0; i < rest.length; i++) {
|
|
12
|
+
if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1]?.startsWith('--') || rest[i + 1] === undefined ? true : rest[++i];
|
|
13
|
+
else words.push(rest[i]);
|
|
14
|
+
}
|
|
15
|
+
const num = (v) => (v === undefined ? undefined : Number(v));
|
|
16
|
+
|
|
17
|
+
if (!cmd || cmd === 'mcp') {
|
|
18
|
+
const { startServer } = await import('./server.js');
|
|
19
|
+
startServer();
|
|
20
|
+
} else if (cmd === 'search') {
|
|
21
|
+
const res = search(words.join(' '), { limit: num(flags.limit) || 20, category: flags.category });
|
|
22
|
+
console.log(res.length ? res.map((i) => `${i.name.padEnd(28)} ${i.category}`).join('\n') : 'No match.');
|
|
23
|
+
} else if (cmd === 'get') {
|
|
24
|
+
const r = getIcon(words[0] || '', { style: flags.style, format: flags.format, size: num(flags.size), color: flags.color, strokeWidth: num(flags['stroke-width']) });
|
|
25
|
+
(r.ok ? console.log : console.error)(r.text);
|
|
26
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
27
|
+
} else if (cmd === 'migrate-lucide') {
|
|
28
|
+
const { migrateDir } = await import('./migrate.js');
|
|
29
|
+
const r = migrateDir(words[0] || '.', { dry: Boolean(flags.dry) });
|
|
30
|
+
console.log(`✓ ${r.swapped} icon import(s) in ${r.files} file(s) switched to @nodo-icons/react${flags.dry ? ' (dry run, nothing written)' : ''}.`);
|
|
31
|
+
if (r.kept.size) console.log(`Kept on lucide-react (no Nodo equivalent yet): ${[...r.kept].join(', ')}`);
|
|
32
|
+
if (r.swapped && !flags.dry) console.log('Next: npm install @nodo-icons/react');
|
|
33
|
+
} else if (cmd === 'styles') {
|
|
34
|
+
for (const s of styles()) console.log(`${s.style.padEnd(10)} ${s.tier}`);
|
|
35
|
+
} else if (cmd === 'categories') {
|
|
36
|
+
for (const c of categories()) console.log(`${c.category.padEnd(16)} ${c.count}`);
|
|
37
|
+
} else {
|
|
38
|
+
console.log('Usage: nodo-icons [mcp] | search <words> | get <name> [--style s] [--format svg|react|vue|sprite] | migrate-lucide [dir] [--dry] | styles | categories');
|
|
39
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Icon catalogue for agents: search, metadata and code snippets. No dependencies.
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { STYLE_INFO, STYLES, toSVG, componentName } from './render.js';
|
|
5
|
+
|
|
6
|
+
const DATA = JSON.parse(readFileSync(new URL('../data/icons.json', import.meta.url), 'utf8'));
|
|
7
|
+
export const FREE_STYLE = DATA.free;
|
|
8
|
+
export const VERSION = DATA.version;
|
|
9
|
+
export const PRO_URL = 'https://nodoicons.com/#pricing';
|
|
10
|
+
const PRO_DIR = process.env.NODO_PRO_DIR || ''; // folder of the unzipped Pro download (contains svg/<style>/)
|
|
11
|
+
|
|
12
|
+
export const icons = DATA.icons.map(([name, category, tags]) => ({ name, category, tags }));
|
|
13
|
+
const byName = new Map(icons.map((i) => [i.name, i]));
|
|
14
|
+
|
|
15
|
+
export const isFree = (style) => style === FREE_STYLE;
|
|
16
|
+
export const proAvailable = () => Boolean(PRO_DIR) && existsSync(join(PRO_DIR, 'svg'));
|
|
17
|
+
|
|
18
|
+
export function styles() {
|
|
19
|
+
return STYLES.map((s) => ({
|
|
20
|
+
style: s,
|
|
21
|
+
label: STYLE_INFO[s].label,
|
|
22
|
+
tier: isFree(s) ? 'free (MIT)' : 'pro',
|
|
23
|
+
paint: STYLE_INFO[s].paint,
|
|
24
|
+
...(STYLE_INFO[s].strokeWidth ? { strokeWidth: STYLE_INFO[s].strokeWidth } : {}),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function categories() {
|
|
29
|
+
const c = new Map();
|
|
30
|
+
for (const i of icons) c.set(i.category, (c.get(i.category) || 0) + 1);
|
|
31
|
+
return [...c].sort((a, b) => b[1] - a[1]).map(([category, count]) => ({ category, count }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ranked search over names, tags and categories; every word of the query must match something. */
|
|
35
|
+
export function search(query, { category, limit = 20 } = {}) {
|
|
36
|
+
const words = String(query || '').toLowerCase().split(/[\s,]+/).filter(Boolean);
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const i of icons) {
|
|
39
|
+
if (category && i.category !== category) continue;
|
|
40
|
+
let score = 0, ok = true;
|
|
41
|
+
for (const w of words) {
|
|
42
|
+
let s = 0;
|
|
43
|
+
if (i.name === w) s = 100;
|
|
44
|
+
else if (i.name.split('-').includes(w)) s = 70;
|
|
45
|
+
else if (i.name.startsWith(w)) s = 60;
|
|
46
|
+
else if (i.name.includes(w)) s = 40;
|
|
47
|
+
if (i.tags.includes(w)) s = Math.max(s, 35);
|
|
48
|
+
else if (i.tags.some((t) => t.startsWith(w))) s = Math.max(s, 18);
|
|
49
|
+
if (i.category === w) s = Math.max(s, 12);
|
|
50
|
+
if (!s) { ok = false; break; }
|
|
51
|
+
score += s;
|
|
52
|
+
}
|
|
53
|
+
if (ok) out.push({ ...i, score: score - i.name.length * 0.1 });
|
|
54
|
+
}
|
|
55
|
+
if (!words.length) return icons.slice(0, limit);
|
|
56
|
+
return out.sort((a, b) => b.score - a.score).slice(0, limit).map(({ score, ...i }) => i);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function suggest(name) {
|
|
60
|
+
return search(name.replace(/-/g, ' '), { limit: 5 }).map((i) => i.name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function svgFor(name, style, opts) {
|
|
64
|
+
if (isFree(style)) {
|
|
65
|
+
return toSVG({ styles: { [style]: { nodes: DATA.linear[name] } } }, style, opts);
|
|
66
|
+
}
|
|
67
|
+
if (!proAvailable()) return null;
|
|
68
|
+
const file = join(PRO_DIR, 'svg', style, `${name}.svg`);
|
|
69
|
+
if (!existsSync(file)) return null;
|
|
70
|
+
let svg = readFileSync(file, 'utf8').trim();
|
|
71
|
+
if (opts.size) svg = svg.replace(/width="[^"]*"/, `width="${opts.size}"`).replace(/height="[^"]*"/, `height="${opts.size}"`);
|
|
72
|
+
if (opts.color && opts.color !== 'currentColor') svg = svg.replaceAll('currentColor', opts.color);
|
|
73
|
+
return svg;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Code for one icon. format: svg | react | vue | sprite.
|
|
78
|
+
* Returns { ok, text } — text is ready to paste, with a one-line note when Pro is involved.
|
|
79
|
+
*/
|
|
80
|
+
export function getIcon(name, { style = FREE_STYLE, format = 'svg', size = 24, color = 'currentColor', strokeWidth } = {}) {
|
|
81
|
+
const icon = byName.get(name);
|
|
82
|
+
if (!icon) {
|
|
83
|
+
const alt = suggest(name);
|
|
84
|
+
return { ok: false, text: `No icon named "${name}".${alt.length ? ` Closest: ${alt.join(', ')}.` : ''}` };
|
|
85
|
+
}
|
|
86
|
+
if (!STYLE_INFO[style]) return { ok: false, text: `Unknown style "${style}". Styles: ${STYLES.join(', ')}.` };
|
|
87
|
+
const comp = componentName(name);
|
|
88
|
+
const pro = !isFree(style);
|
|
89
|
+
const pkg = (fw) => `@nodo-icons/${fw}`;
|
|
90
|
+
const sw = strokeWidth && STYLE_INFO[style].paint === 'stroke' ? strokeWidth : undefined;
|
|
91
|
+
|
|
92
|
+
// Pro styles have no React/Vue package yet: hand back their SVG from the Pro download instead.
|
|
93
|
+
if (pro && (format === 'react' || format === 'vue')) {
|
|
94
|
+
const soon = `React/Vue packages for Nodo Icons Pro styles are not released yet; the free Linear style is "@nodo-icons/${format}".`;
|
|
95
|
+
const svg = svgFor(name, style, { size, color, strokeWidth: sw });
|
|
96
|
+
if (svg) return { ok: true, text: `${svg}\n\n<!-- ${STYLE_INFO[style].label} SVG from your Pro download. ${soon} -->` };
|
|
97
|
+
return { ok: false, text: `"${name}" in ${STYLE_INFO[style].label} is a Nodo Icons Pro style. With a Pro license, set NODO_PRO_DIR to the unzipped Pro download to get its SVG (${PRO_URL}). ${soon}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (format === 'react') {
|
|
101
|
+
const props = [`size={${size}}`, color !== 'currentColor' ? `color="${color}"` : '', sw ? `strokeWidth={${sw}}` : ''].filter(Boolean).join(' ');
|
|
102
|
+
return { ok: true, text: `import { ${comp} } from '${pkg('react')}';\n\n<${comp} ${props} />` };
|
|
103
|
+
}
|
|
104
|
+
if (format === 'vue') {
|
|
105
|
+
const props = [`:size="${size}"`, color !== 'currentColor' ? `color="${color}"` : '', sw ? `:stroke-width="${sw}"` : ''].filter(Boolean).join(' ');
|
|
106
|
+
return { ok: true, text: `<script setup>\nimport { ${comp} } from '${pkg('vue')}';\n</script>\n\n<template><${comp} ${props} /></template>` };
|
|
107
|
+
}
|
|
108
|
+
if (format === 'sprite') {
|
|
109
|
+
const htmlNote = pro ? `\n<!-- ${STYLE_INFO[style].label} is a Nodo Icons Pro style: it needs a Pro license (${PRO_URL}). -->` : '';
|
|
110
|
+
return { ok: true, text: `<svg width="${size}" height="${size}" aria-hidden="true"><use href="sprite/${style}.svg#nd-${name}"/></svg>${htmlNote}` };
|
|
111
|
+
}
|
|
112
|
+
const svg = svgFor(name, style, { size, color, strokeWidth: sw });
|
|
113
|
+
if (!svg) {
|
|
114
|
+
return { ok: false, text: `"${name}" in ${STYLE_INFO[style].label} is a Nodo Icons Pro style. With a Pro license, set NODO_PRO_DIR to the unzipped Pro download to get its SVG here (${PRO_URL}). The free Linear SVG is available now: use style "${FREE_STYLE}".` };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, text: svg };
|
|
117
|
+
}
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Codemod: switch a project's lucide-react imports to @nodo-icons/react, keeping local names,
|
|
2
|
+
// so JSX does not change: import { ChevronRight } from "lucide-react"
|
|
3
|
+
// → import { ChevronRightIcon as ChevronRight } from "@nodo-icons/react"
|
|
4
|
+
// Icons without a Nodo equivalent stay on lucide-react and are reported.
|
|
5
|
+
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, extname } from 'node:path';
|
|
7
|
+
import { icons } from './core.js';
|
|
8
|
+
import { componentName } from './render.js';
|
|
9
|
+
|
|
10
|
+
const SHADCN = JSON.parse(readFileSync(new URL('../data/shadcn-map.json', import.meta.url), 'utf8'));
|
|
11
|
+
const NAMES = new Set(icons.map((i) => i.name));
|
|
12
|
+
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();
|
|
13
|
+
|
|
14
|
+
/** lucide export name (ChevronRight, ChevronRightIcon, LucideChevronRight) → Nodo component or null */
|
|
15
|
+
export function nodoFor(lucideName) {
|
|
16
|
+
const base = lucideName.replace(/^Lucide/, '').replace(/Icon$/, '');
|
|
17
|
+
const viaShadcn = SHADCN[`${base}Icon`];
|
|
18
|
+
if (viaShadcn) return viaShadcn;
|
|
19
|
+
const k = kebab(base);
|
|
20
|
+
return NAMES.has(k) ? componentName(k) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function migrateSource(src) {
|
|
24
|
+
const kept = [], done = [];
|
|
25
|
+
const out = src.replace(/import\s*(type\s+)?\{([^}]*)\}\s*from\s*(['"])lucide-react\3[ \t]*;?/g, (stmt, typeOnly, list, q) => {
|
|
26
|
+
if (typeOnly) return stmt;
|
|
27
|
+
const specs = list.split(',').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
const nodo = [], lucide = [];
|
|
29
|
+
for (const spec of specs) {
|
|
30
|
+
const [imported, local = imported] = spec.split(/\s+as\s+/).map((s) => s.trim());
|
|
31
|
+
const comp = /^(type\s|LucideIcon$|LucideProps$|icons$|createLucideIcon$)/.test(imported) ? null : nodoFor(imported);
|
|
32
|
+
if (comp) { nodo.push(comp === local ? comp : `${comp} as ${local}`); done.push(`${imported}→${comp}`); }
|
|
33
|
+
else { lucide.push(spec); if (!/^type\s/.test(imported)) kept.push(imported); }
|
|
34
|
+
}
|
|
35
|
+
const semi = stmt.trim().endsWith(';') ? ';' : '';
|
|
36
|
+
return [
|
|
37
|
+
nodo.length ? `import { ${nodo.join(', ')} } from ${q}@nodo-icons/react${q}${semi}` : '',
|
|
38
|
+
lucide.length ? `import { ${lucide.join(', ')} } from ${q}lucide-react${q}${semi}` : '',
|
|
39
|
+
].filter(Boolean).join('\n');
|
|
40
|
+
});
|
|
41
|
+
return { out, done, kept };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function* walk(dir) {
|
|
45
|
+
for (const f of readdirSync(dir)) {
|
|
46
|
+
if (f === 'node_modules' || f.startsWith('.') || f === 'dist' || f === 'build') continue;
|
|
47
|
+
const p = join(dir, f);
|
|
48
|
+
if (statSync(p).isDirectory()) yield* walk(p);
|
|
49
|
+
else if (['.tsx', '.jsx', '.ts', '.js', '.mdx'].includes(extname(p))) yield p;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function migrateDir(dir, { dry = false } = {}) {
|
|
54
|
+
const report = { files: 0, swapped: 0, kept: new Set() };
|
|
55
|
+
for (const f of walk(dir)) {
|
|
56
|
+
const src = readFileSync(f, 'utf8');
|
|
57
|
+
if (!src.includes('lucide-react')) continue;
|
|
58
|
+
const { out, done, kept } = migrateSource(src);
|
|
59
|
+
kept.forEach((k) => report.kept.add(k));
|
|
60
|
+
if (out !== src) {
|
|
61
|
+
report.files++; report.swapped += done.length;
|
|
62
|
+
console.log(`${dry ? '[dry] ' : ''}${f}: ${done.length} icon(s)${kept.length ? `, ${kept.length} kept on lucide-react` : ''}`);
|
|
63
|
+
if (!dry) writeFileSync(f, out);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return report;
|
|
67
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Turns compiled icon data (src/generated/icons.json) into SVG markup.
|
|
2
|
+
// Shared by the build scripts and the preview site, so it stays dependency-free.
|
|
3
|
+
|
|
4
|
+
// The one style table. Everything (SVG, sprite, React, Vue, fonts, site, verify) reads from here.
|
|
5
|
+
// paint 'stroke' → root draws strokes; 'fill' → root draws fills
|
|
6
|
+
// layers true when the icon font needs a second (secondary) glyph layer, drawn at layerOpacity
|
|
7
|
+
export const STYLE_INFO = {
|
|
8
|
+
linear: { label: 'Linear', paint: 'stroke', strokeWidth: 1.5, blurb: 'Contorno 1.5px, terminali arrotondati. Neutro e leggero.' },
|
|
9
|
+
light: { label: 'Light', paint: 'stroke', strokeWidth: 1, blurb: 'Contorno 1px: elegante, per dimensioni grandi e UI ariose.' },
|
|
10
|
+
bold: { label: 'Bold', paint: 'stroke', strokeWidth: 2, blurb: 'Contorno 2px: più presenza a 16–20px e su sfondi rumorosi.' },
|
|
11
|
+
sharp: { label: 'Sharp', paint: 'stroke', strokeWidth: 1.5, cap: 'butt', join: 'miter', blurb: 'Angoli vivi e terminali netti. Tecnico, editoriale.' },
|
|
12
|
+
twotone: { label: 'Twotone', paint: 'stroke', strokeWidth: 1.5, layers: true, layerOpacity: 0.4, blurb: 'Contorno con i dettagli secondari al 40%.' },
|
|
13
|
+
duotone: { label: 'Duotone', paint: 'stroke', strokeWidth: 1.5, layers: true, layerOpacity: 0.3, blurb: 'Contorno + riempimento tonale al 30%. Più profondità.' },
|
|
14
|
+
bulk: { label: 'Bulk', paint: 'stroke', strokeWidth: 1.5, layers: true, layerOpacity: 0.4, blurb: 'Sagoma piena al 40% con i dettagli in primo piano.' },
|
|
15
|
+
solid: { label: 'Solid', paint: 'fill', blurb: 'Forme piene con dettagli in negativo. Massima leggibilità.' },
|
|
16
|
+
contained: { label: 'Contained', paint: 'fill', blurb: 'Glifo scavato in un quadrato arrotondato, stile app-icon.' },
|
|
17
|
+
soft: { label: 'Soft', paint: 'stroke', strokeWidth: 1.5, layers: true, layerOpacity: 0.12, blurb: 'Glifo su tessera tinta al 12%: per chip, liste, empty state.' },
|
|
18
|
+
};
|
|
19
|
+
export const STYLES = Object.keys(STYLE_INFO);
|
|
20
|
+
|
|
21
|
+
export function rootAttrs(style, { strokeWidth, outlined = false } = {}) {
|
|
22
|
+
const s = STYLE_INFO[style];
|
|
23
|
+
if (s.paint === 'fill' || outlined) return { fill: 'currentColor' };
|
|
24
|
+
return {
|
|
25
|
+
fill: 'none',
|
|
26
|
+
stroke: 'currentColor',
|
|
27
|
+
'stroke-width': strokeWidth ?? s.strokeWidth,
|
|
28
|
+
'stroke-linecap': s.cap || 'round',
|
|
29
|
+
'stroke-linejoin': s.join || 'round',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function iconNodes(icon, style, { outlined = false } = {}) {
|
|
34
|
+
if (!outlined) return icon.styles[style].nodes;
|
|
35
|
+
const { primary, secondary } = icon.styles[style].outline;
|
|
36
|
+
return [...(secondary ? [['path', { d: secondary, opacity: String(STYLE_INFO[style].layerOpacity) }]] : []), ['path', { d: primary }]];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const esc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
40
|
+
const attrs = (o) =>
|
|
41
|
+
Object.entries(o)
|
|
42
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
43
|
+
.map(([k, v]) => ` ${k}="${esc(v)}"`)
|
|
44
|
+
.join('');
|
|
45
|
+
|
|
46
|
+
export function nodesToMarkup(nodes) {
|
|
47
|
+
return nodes.map(([tag, a]) => `<${tag}${attrs(a)}/>`).join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function toSVG(icon, style, { size = 24, color = 'currentColor', strokeWidth, outlined = false, title } = {}) {
|
|
51
|
+
const root = {
|
|
52
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
53
|
+
width: size,
|
|
54
|
+
height: size,
|
|
55
|
+
viewBox: '0 0 24 24',
|
|
56
|
+
...rootAttrs(style, { strokeWidth, outlined }),
|
|
57
|
+
};
|
|
58
|
+
let body = nodesToMarkup(iconNodes(icon, style, { outlined }));
|
|
59
|
+
if (title) body = `<title>${esc(title)}</title>${body}`;
|
|
60
|
+
const svg = `<svg${attrs(root)}>${body}</svg>`;
|
|
61
|
+
return color === 'currentColor' ? svg : svg.replaceAll('currentColor', color);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const pascal = (name) => name.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
|
|
65
|
+
// JS identifiers can't start with a digit: `5g` → Icon5g, `3d-printer` → Icon3dPrinter; the rest → HomeIcon.
|
|
66
|
+
export const componentName = (name) => (/^\d/.test(name) ? `Icon${pascal(name)}` : `${pascal(name)}Icon`);
|
package/src/server.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Minimal MCP server over stdio (JSON-RPC 2.0, one message per line). No dependencies.
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { search, getIcon, styles, categories, VERSION, FREE_STYLE, proAvailable } from './core.js';
|
|
4
|
+
|
|
5
|
+
const SUPPORTED = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
6
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
7
|
+
const ok = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
8
|
+
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
9
|
+
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
|
|
10
|
+
|
|
11
|
+
const STYLE_ENUM = styles().map((s) => s.style);
|
|
12
|
+
const TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'search_icons',
|
|
15
|
+
description: `Search the Nodo Icons catalogue (5,000 UI icons on a 24×24 grid) by name, keyword or synonym. Returns icon names to use with get_icon. Use simple English words, e.g. "upload", "credit card", "settings".`,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
query: { type: 'string', description: 'Words describing the icon, e.g. "trash", "user add", "cloud rain".' },
|
|
20
|
+
category: { type: 'string', description: 'Optional category filter (see list_categories).' },
|
|
21
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
22
|
+
},
|
|
23
|
+
required: ['query'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'get_icon',
|
|
28
|
+
description: `Get ready-to-paste code for one Nodo icon: inline SVG, a React or Vue component import, or an SVG sprite reference. The "${FREE_STYLE}" style is free (MIT); the other styles are Nodo Icons Pro.`,
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
name: { type: 'string', description: 'Exact icon name from search_icons, e.g. "credit-card".' },
|
|
33
|
+
style: { type: 'string', enum: STYLE_ENUM, default: FREE_STYLE },
|
|
34
|
+
format: { type: 'string', enum: ['svg', 'react', 'vue', 'sprite'], default: 'svg' },
|
|
35
|
+
size: { type: 'number', default: 24 },
|
|
36
|
+
color: { type: 'string', default: 'currentColor', description: 'Any CSS color. Default inherits the text color.' },
|
|
37
|
+
strokeWidth: { type: 'number', description: 'Only for stroke styles. Default depends on the style (Linear 1.5).' },
|
|
38
|
+
},
|
|
39
|
+
required: ['name'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{ name: 'list_styles', description: 'List the ten Nodo Icons styles, which are free or Pro, and their stroke settings.', inputSchema: { type: 'object', properties: {} } },
|
|
43
|
+
{ name: 'list_categories', description: 'List icon categories with the number of icons in each.', inputSchema: { type: 'object', properties: {} } },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function call(name, a = {}) {
|
|
47
|
+
switch (name) {
|
|
48
|
+
case 'search_icons': {
|
|
49
|
+
const res = search(a.query, { category: a.category, limit: a.limit || 20 });
|
|
50
|
+
if (!res.length) return text(`No icons match "${a.query}". Try a simpler or more generic word.`);
|
|
51
|
+
return text(res.map((i) => `${i.name} (${i.category}; ${i.tags.join(', ')})`).join('\n'));
|
|
52
|
+
}
|
|
53
|
+
case 'get_icon': {
|
|
54
|
+
const r = getIcon(String(a.name || ''), a);
|
|
55
|
+
return text(r.text, !r.ok);
|
|
56
|
+
}
|
|
57
|
+
case 'list_styles':
|
|
58
|
+
return text(JSON.stringify({ styles: styles(), proFilesConfigured: proAvailable() }, null, 2));
|
|
59
|
+
case 'list_categories':
|
|
60
|
+
return text(categories().map((c) => `${c.category}: ${c.count}`).join('\n'));
|
|
61
|
+
default:
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function startServer() {
|
|
67
|
+
const rl = createInterface({ input: process.stdin });
|
|
68
|
+
rl.on('line', (line) => {
|
|
69
|
+
if (!line.trim()) return;
|
|
70
|
+
let msg;
|
|
71
|
+
try { msg = JSON.parse(line); } catch { return fail(null, -32700, 'Parse error'); }
|
|
72
|
+
const { id, method, params } = msg;
|
|
73
|
+
if (id === undefined) return; // notifications (e.g. notifications/initialized) need no reply
|
|
74
|
+
try {
|
|
75
|
+
if (method === 'initialize') {
|
|
76
|
+
const asked = params?.protocolVersion;
|
|
77
|
+
return ok(id, {
|
|
78
|
+
protocolVersion: SUPPORTED.includes(asked) ? asked : SUPPORTED[0],
|
|
79
|
+
capabilities: { tools: {} },
|
|
80
|
+
serverInfo: { name: 'nodo-icons', version: VERSION },
|
|
81
|
+
instructions: 'Use search_icons to find an icon name, then get_icon to get SVG or React/Vue code. Prefer the free "linear" style unless the user has Nodo Icons Pro.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (method === 'ping') return ok(id, {});
|
|
85
|
+
if (method === 'tools/list') return ok(id, { tools: TOOLS });
|
|
86
|
+
if (method === 'tools/call') {
|
|
87
|
+
const res = call(params?.name, params?.arguments);
|
|
88
|
+
return res ? ok(id, res) : fail(id, -32602, `Unknown tool: ${params?.name}`);
|
|
89
|
+
}
|
|
90
|
+
return fail(id, -32601, `Method not found: ${method}`);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return fail(id, -32603, e.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|