reicon-mcp 1.0.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 +21 -0
- package/README.md +69 -0
- package/bin/run.cjs +20 -0
- package/cli/index.d.ts +1 -0
- package/cli/index.js +130 -0
- package/core/codegen.d.ts +7 -0
- package/core/codegen.js +96 -0
- package/core/index-builder.d.ts +11 -0
- package/core/index-builder.js +63 -0
- package/core/load-index.d.ts +2 -0
- package/core/load-index.js +28 -0
- package/core/search.d.ts +13 -0
- package/core/search.js +134 -0
- package/core/types.d.ts +36 -0
- package/core/types.js +1 -0
- package/data/icon-index.json +1 -0
- package/package.json +47 -0
- package/server/index.d.ts +1 -0
- package/server/index.js +57 -0
- package/server/tools/apply-icon.d.ts +4 -0
- package/server/tools/apply-icon.js +11 -0
- package/server/tools/list-categories.d.ts +3 -0
- package/server/tools/list-categories.js +6 -0
- package/server/tools/search-icons.d.ts +11 -0
- package/server/tools/search-icons.js +9 -0
- package/server/tools/view-icon.d.ts +12 -0
- package/server/tools/view-icon.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dev Chauhan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# reicon-mcp
|
|
2
|
+
|
|
3
|
+
MCP server and CLI for searching, previewing, and applying Reicon icons. Works fully offline with a prebuilt search index.
|
|
4
|
+
|
|
5
|
+
## MCP Server
|
|
6
|
+
|
|
7
|
+
Add to your MCP client config:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"reicon": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["reicon-mcp"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or point directly at the local binary after building:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"reicon": {
|
|
26
|
+
"command": "node",
|
|
27
|
+
"args": ["/path/to/reicon/packages/reicon-mcp/bin/run.cjs"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Tools
|
|
34
|
+
|
|
35
|
+
| Tool | Description |
|
|
36
|
+
| :--- | :--- |
|
|
37
|
+
| `search_icons` | Rank icons by keyword |
|
|
38
|
+
| `view_icon` | Return raw SVG markup and metadata |
|
|
39
|
+
| `apply_icon` | Generate framework-specific import and usage snippets |
|
|
40
|
+
| `list_categories` | List all icon categories |
|
|
41
|
+
|
|
42
|
+
## CLI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx reicon-mcp search "shopping cart"
|
|
46
|
+
npx reicon-mcp view heart --weight Filled
|
|
47
|
+
npx reicon-mcp apply heart --framework react --size 32 --color "#ef4444"
|
|
48
|
+
npx reicon-mcp categories
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### File write mode
|
|
52
|
+
|
|
53
|
+
For scripted insertion, replace a marker string in a target file:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx reicon-mcp apply heart --framework react --file src/App.tsx --marker "{/* ICON */}"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The marker must exist exactly once. The command exits non-zero if the marker is not found.
|
|
60
|
+
|
|
61
|
+
## Build
|
|
62
|
+
|
|
63
|
+
From the monorepo root:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm run build:mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
MIT © [Dev Chauhan](https://devchauhan.in)
|
package/bin/run.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('fs');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
const { pathToFileURL } = require('url');
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const root = join(__dirname, '..');
|
|
9
|
+
const base = existsSync(join(root, 'server', 'index.js')) ? root : join(root, 'dist');
|
|
10
|
+
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
import(pathToFileURL(join(base, 'server/index.js')).href);
|
|
13
|
+
} else {
|
|
14
|
+
import(pathToFileURL(join(base, 'cli/index.js')).href).then((mod) => {
|
|
15
|
+
mod.run(args).catch((err) => {
|
|
16
|
+
console.error(err);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
package/cli/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function run(argv: string[]): Promise<void>;
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { handleApplyIcon } from '../server/tools/apply-icon.js';
|
|
3
|
+
import { handleListCategories } from '../server/tools/list-categories.js';
|
|
4
|
+
import { handleSearchIcons } from '../server/tools/search-icons.js';
|
|
5
|
+
import { handleViewIcon } from '../server/tools/view-icon.js';
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const result = {};
|
|
8
|
+
const positional = [];
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (arg.startsWith('--')) {
|
|
12
|
+
const key = arg.slice(2);
|
|
13
|
+
const next = argv[i + 1];
|
|
14
|
+
if (next && !next.startsWith('--')) {
|
|
15
|
+
result[key] = next;
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
result[key] = 'true';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
positional.push(arg);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { positional, flags: result };
|
|
27
|
+
}
|
|
28
|
+
function print(data) {
|
|
29
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30
|
+
}
|
|
31
|
+
function applyToFile(filePath, marker, importStatement, usageSnippet) {
|
|
32
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
33
|
+
if (!content.includes(marker)) {
|
|
34
|
+
console.error(`Marker not found in ${filePath}: ${marker}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
let updated = content.replace(marker, usageSnippet);
|
|
38
|
+
if (importStatement && !updated.includes(importStatement)) {
|
|
39
|
+
const lines = updated.split('\n');
|
|
40
|
+
let insertAt = 0;
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
if (lines[i].startsWith('import ') || lines[i].startsWith('<script')) {
|
|
43
|
+
insertAt = i + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (insertAt === 0) {
|
|
47
|
+
lines.unshift(importStatement);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
lines.splice(insertAt, 0, importStatement);
|
|
51
|
+
}
|
|
52
|
+
updated = lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
writeFileSync(filePath, updated, 'utf-8');
|
|
55
|
+
print({ ok: true, file: filePath });
|
|
56
|
+
}
|
|
57
|
+
export async function run(argv) {
|
|
58
|
+
const { positional, flags } = parseArgs(argv);
|
|
59
|
+
const command = positional[0];
|
|
60
|
+
if (!command) {
|
|
61
|
+
console.error('Usage: reicon-mcp <search|view|apply|categories> [args]');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
switch (command) {
|
|
65
|
+
case 'search': {
|
|
66
|
+
const query = positional.slice(1).join(' ');
|
|
67
|
+
if (!query) {
|
|
68
|
+
console.error('Usage: reicon-mcp search <query>');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const result = handleSearchIcons({
|
|
72
|
+
query,
|
|
73
|
+
weight: flags.weight,
|
|
74
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
75
|
+
});
|
|
76
|
+
print(result);
|
|
77
|
+
if ('error' in result)
|
|
78
|
+
process.exit(1);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'view': {
|
|
82
|
+
const name = positional[1];
|
|
83
|
+
if (!name) {
|
|
84
|
+
console.error('Usage: reicon-mcp view <name> --weight Outline|Filled');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const weight = flags.weight || 'Outline';
|
|
88
|
+
const result = handleViewIcon({ name, weight });
|
|
89
|
+
print(result);
|
|
90
|
+
if ('error' in result)
|
|
91
|
+
process.exit(1);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'apply': {
|
|
95
|
+
const name = positional[1];
|
|
96
|
+
if (!name) {
|
|
97
|
+
console.error('Usage: reicon-mcp apply <name> --framework react|vue|svelte|html|svg');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const framework = flags.framework || 'react';
|
|
101
|
+
const weight = flags.weight || 'Outline';
|
|
102
|
+
const result = handleApplyIcon({
|
|
103
|
+
name,
|
|
104
|
+
weight,
|
|
105
|
+
framework,
|
|
106
|
+
size: flags.size ? Number(flags.size) : undefined,
|
|
107
|
+
color: flags.color,
|
|
108
|
+
componentName: flags.componentName,
|
|
109
|
+
});
|
|
110
|
+
if ('error' in result) {
|
|
111
|
+
print(result);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (flags.file && flags.marker) {
|
|
115
|
+
applyToFile(flags.file, flags.marker, result.importStatement, result.usageSnippet);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
print(result);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'categories': {
|
|
123
|
+
print(handleListCategories());
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
console.error(`Unknown command: ${command}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ApplyIconInput, ApplyIconOutput, IconEntry, IconWeight } from './types.js';
|
|
2
|
+
export declare function generateCode(icon: IconEntry, input: ApplyIconInput): ApplyIconOutput | {
|
|
3
|
+
error: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function buildSvgMarkup(icon: IconEntry, weight: IconWeight): string | {
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
package/core/codegen.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function weightProp(weight, framework) {
|
|
2
|
+
if (weight === 'Outline')
|
|
3
|
+
return '';
|
|
4
|
+
if (framework === 'html')
|
|
5
|
+
return ' weight="filled"';
|
|
6
|
+
return ` weight="${weight}"`;
|
|
7
|
+
}
|
|
8
|
+
function sizeProp(size, framework) {
|
|
9
|
+
if (framework === 'vue')
|
|
10
|
+
return ` :size="${size}"`;
|
|
11
|
+
if (framework === 'html')
|
|
12
|
+
return ` size="${size}"`;
|
|
13
|
+
if (framework === 'svelte' || framework === 'react')
|
|
14
|
+
return ` size={${size}}`;
|
|
15
|
+
return ` width="${size}" height="${size}"`;
|
|
16
|
+
}
|
|
17
|
+
function colorProp(color, framework) {
|
|
18
|
+
if (framework === 'vue')
|
|
19
|
+
return ` color="${color}"`;
|
|
20
|
+
if (framework === 'html')
|
|
21
|
+
return ` color="${color}"`;
|
|
22
|
+
if (framework === 'svelte' || framework === 'react') {
|
|
23
|
+
const val = color === 'currentColor' ? 'currentColor' : `"${color}"`;
|
|
24
|
+
return ` color={${val}}`;
|
|
25
|
+
}
|
|
26
|
+
return ` color="${color}"`;
|
|
27
|
+
}
|
|
28
|
+
export function generateCode(icon, input) {
|
|
29
|
+
const size = input.size ?? 24;
|
|
30
|
+
const color = input.color ?? 'currentColor';
|
|
31
|
+
const component = input.componentName ?? icon.pascal;
|
|
32
|
+
const weight = input.weight;
|
|
33
|
+
const weightData = icon.weights[weight];
|
|
34
|
+
const hasColor = input.color !== undefined;
|
|
35
|
+
if (!weightData) {
|
|
36
|
+
return { error: `Icon "${icon.name}" does not have a ${weight} weight.` };
|
|
37
|
+
}
|
|
38
|
+
const colorAttr = (framework) => hasColor ? colorProp(color, framework) : '';
|
|
39
|
+
switch (input.framework) {
|
|
40
|
+
case 'react': {
|
|
41
|
+
const importStatement = `import { ${component} } from 'reicon-react';`;
|
|
42
|
+
const props = [
|
|
43
|
+
sizeProp(size, 'react'),
|
|
44
|
+
colorAttr('react'),
|
|
45
|
+
weightProp(weight, 'react'),
|
|
46
|
+
].join('');
|
|
47
|
+
const usageSnippet = `<${component}${props} />`;
|
|
48
|
+
return { importStatement, usageSnippet };
|
|
49
|
+
}
|
|
50
|
+
case 'vue': {
|
|
51
|
+
const importStatement = `import { ${component} } from 'reicon-vue';`;
|
|
52
|
+
const props = [
|
|
53
|
+
sizeProp(size, 'vue'),
|
|
54
|
+
colorAttr('vue'),
|
|
55
|
+
weightProp(weight, 'vue'),
|
|
56
|
+
].join('');
|
|
57
|
+
const usageSnippet = `<${component}${props} />`;
|
|
58
|
+
return { importStatement, usageSnippet };
|
|
59
|
+
}
|
|
60
|
+
case 'svelte': {
|
|
61
|
+
const importStatement = `import { ${component} } from 'reicon-svelte';`;
|
|
62
|
+
const props = [
|
|
63
|
+
sizeProp(size, 'svelte'),
|
|
64
|
+
colorAttr('svelte'),
|
|
65
|
+
weightProp(weight, 'svelte'),
|
|
66
|
+
].join('');
|
|
67
|
+
const usageSnippet = `<${component}${props} />`;
|
|
68
|
+
return { importStatement, usageSnippet };
|
|
69
|
+
}
|
|
70
|
+
case 'html': {
|
|
71
|
+
const importStatement = '<script src="https://unpkg.com/reicon/cdn/reicon.min.js"></script>';
|
|
72
|
+
const props = [
|
|
73
|
+
` icon="${icon.name}"`,
|
|
74
|
+
sizeProp(size, 'html'),
|
|
75
|
+
colorAttr('html'),
|
|
76
|
+
weightProp(weight, 'html'),
|
|
77
|
+
].join('');
|
|
78
|
+
const usageSnippet = `<re-icon${props}></re-icon>`;
|
|
79
|
+
return { importStatement, usageSnippet };
|
|
80
|
+
}
|
|
81
|
+
case 'svg': {
|
|
82
|
+
const importStatement = '';
|
|
83
|
+
const usageSnippet = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${weightData.viewBox}" fill="none">${weightData.code}</svg>`;
|
|
84
|
+
return { importStatement, usageSnippet };
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return { error: `Unknown framework: ${input.framework}` };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function buildSvgMarkup(icon, weight) {
|
|
91
|
+
const weightData = icon.weights[weight];
|
|
92
|
+
if (!weightData) {
|
|
93
|
+
return { error: `Icon "${icon.name}" does not have a ${weight} weight.` };
|
|
94
|
+
}
|
|
95
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${weightData.viewBox}" fill="none">${weightData.code}</svg>`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IconIndex } from './types.js';
|
|
2
|
+
export declare function buildIconIndex(data: {
|
|
3
|
+
categories?: Record<string, {
|
|
4
|
+
icons?: Record<string, {
|
|
5
|
+
description?: string[];
|
|
6
|
+
weights?: Record<string, {
|
|
7
|
+
code?: string;
|
|
8
|
+
}>;
|
|
9
|
+
}>;
|
|
10
|
+
}>;
|
|
11
|
+
}): IconIndex;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const W_KEYS = ['Outline', 'Filled'];
|
|
2
|
+
const SEARCH_ALIASES = {
|
|
3
|
+
x: ['close', 'dismiss', 'cancel'],
|
|
4
|
+
cart: ['shopping cart', 'shopping'],
|
|
5
|
+
trash: ['delete', 'remove', 'bin'],
|
|
6
|
+
settings: ['gear', 'cog'],
|
|
7
|
+
search: ['magnifier', 'find'],
|
|
8
|
+
user: ['profile', 'account', 'avatar'],
|
|
9
|
+
heart: ['love', 'favorite', 'like'],
|
|
10
|
+
};
|
|
11
|
+
function toPascalCase(str) {
|
|
12
|
+
return str
|
|
13
|
+
.split('-')
|
|
14
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
15
|
+
.join('');
|
|
16
|
+
}
|
|
17
|
+
function stripSvgWrapper(code) {
|
|
18
|
+
if (!code)
|
|
19
|
+
return '';
|
|
20
|
+
return code.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '').trim();
|
|
21
|
+
}
|
|
22
|
+
function rewriteColors(svg) {
|
|
23
|
+
return svg.replace(/fill="white"/g, 'fill="currentColor"');
|
|
24
|
+
}
|
|
25
|
+
export function buildIconIndex(data) {
|
|
26
|
+
const icons = [];
|
|
27
|
+
const categorySet = new Set();
|
|
28
|
+
const pascalLowerSet = new Set();
|
|
29
|
+
for (const [catKey, catData] of Object.entries(data.categories || {})) {
|
|
30
|
+
categorySet.add(catKey);
|
|
31
|
+
for (const [iconKey, icon] of Object.entries(catData.icons || {})) {
|
|
32
|
+
let pascal = toPascalCase(iconKey);
|
|
33
|
+
if (pascalLowerSet.has(pascal.toLowerCase())) {
|
|
34
|
+
pascal += toPascalCase(catKey);
|
|
35
|
+
}
|
|
36
|
+
pascalLowerSet.add(pascal.toLowerCase());
|
|
37
|
+
const weights = {};
|
|
38
|
+
for (const wName of W_KEYS) {
|
|
39
|
+
const wData = icon.weights?.[wName];
|
|
40
|
+
if (wData?.code) {
|
|
41
|
+
weights[wName] = {
|
|
42
|
+
code: rewriteColors(stripSvgWrapper(wData.code)),
|
|
43
|
+
viewBox: '0 0 24 24',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (Object.keys(weights).length > 0) {
|
|
48
|
+
icons.push({
|
|
49
|
+
name: iconKey,
|
|
50
|
+
pascal,
|
|
51
|
+
category: catKey,
|
|
52
|
+
tags: [...(icon.description || []), ...(SEARCH_ALIASES[iconKey] || [])],
|
|
53
|
+
weights,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
icons.sort((a, b) => a.name.localeCompare(b.name));
|
|
59
|
+
return {
|
|
60
|
+
icons,
|
|
61
|
+
categories: [...categorySet].sort(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
let cached = null;
|
|
5
|
+
function resolveIndexPath() {
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const candidates = [
|
|
8
|
+
join(here, '..', 'data', 'icon-index.json'),
|
|
9
|
+
join(here, '..', '..', 'src', 'data', 'icon-index.json'),
|
|
10
|
+
];
|
|
11
|
+
for (const p of candidates) {
|
|
12
|
+
try {
|
|
13
|
+
readFileSync(p, 'utf-8');
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
throw new Error('icon-index.json not found. Run npm run build:mcp first.');
|
|
21
|
+
}
|
|
22
|
+
export function loadIndex() {
|
|
23
|
+
if (cached)
|
|
24
|
+
return cached;
|
|
25
|
+
const path = resolveIndexPath();
|
|
26
|
+
cached = JSON.parse(readFileSync(path, 'utf-8'));
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
package/core/search.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IconEntry, IconIndex, IconWeight, SearchResult } from './types.js';
|
|
2
|
+
export declare function isSentenceQuery(query: string): boolean;
|
|
3
|
+
export declare function searchIcons(index: IconIndex, query: string, options?: {
|
|
4
|
+
weight?: IconWeight;
|
|
5
|
+
limit?: number;
|
|
6
|
+
}): {
|
|
7
|
+
results: SearchResult[];
|
|
8
|
+
instruction: string;
|
|
9
|
+
} | {
|
|
10
|
+
error: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function findIcon(index: IconIndex, name: string): IconEntry | undefined;
|
|
13
|
+
export declare function listCategories(index: IconIndex): string[];
|
package/core/search.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const SENTENCE_MARKERS = /\?|!|\.{2,}/;
|
|
2
|
+
const SENTENCE_WORDS = /\b(please|could|would|want|need|show|find|give|get|me|my|the|a|an|icon for|looking for|i am|i'm|can you|help me)\b/i;
|
|
3
|
+
const SYNONYMS = {
|
|
4
|
+
close: ['x'],
|
|
5
|
+
cart: ['cart', 'cart-large', 'shopping-cart2'],
|
|
6
|
+
'shopping cart': ['cart', 'cart3', 'shopping-cart2', 'cart-large'],
|
|
7
|
+
delete: ['trash', 'trash-bin-trash'],
|
|
8
|
+
remove: ['trash', 'x'],
|
|
9
|
+
settings: ['settings', 'settings-minimalistic'],
|
|
10
|
+
arrow: ['arrow-right', 'arrow-down', 'arrow-up', 'arrow-left'],
|
|
11
|
+
search: ['search', 'minimalistic-magnifer'],
|
|
12
|
+
heart: ['heart', 'heart-angle'],
|
|
13
|
+
trash: ['trash', 'trash-bin-trash'],
|
|
14
|
+
user: ['user', 'user-circle'],
|
|
15
|
+
};
|
|
16
|
+
function normalize(text) {
|
|
17
|
+
return text.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
18
|
+
}
|
|
19
|
+
function tokenize(text) {
|
|
20
|
+
return normalize(text).split(' ').filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
export function isSentenceQuery(query) {
|
|
23
|
+
const trimmed = query.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
return true;
|
|
26
|
+
if (SENTENCE_MARKERS.test(trimmed))
|
|
27
|
+
return true;
|
|
28
|
+
const words = tokenize(trimmed);
|
|
29
|
+
if (words.length > 6)
|
|
30
|
+
return true;
|
|
31
|
+
if (words.length >= 4 && SENTENCE_WORDS.test(trimmed))
|
|
32
|
+
return true;
|
|
33
|
+
if (/^(how|what|where|which|why|when)\b/i.test(trimmed))
|
|
34
|
+
return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function synonymBoost(query, name) {
|
|
38
|
+
const key = normalize(query);
|
|
39
|
+
const list = SYNONYMS[key];
|
|
40
|
+
if (!list)
|
|
41
|
+
return 0;
|
|
42
|
+
const idx = list.indexOf(name);
|
|
43
|
+
if (idx === -1)
|
|
44
|
+
return 0;
|
|
45
|
+
return 3000 - idx * 100;
|
|
46
|
+
}
|
|
47
|
+
function scoreIcon(icon, query, tokens, weight) {
|
|
48
|
+
const q = normalize(query);
|
|
49
|
+
const name = icon.name;
|
|
50
|
+
const nameNorm = name.replace(/-/g, ' ');
|
|
51
|
+
const tagsNorm = icon.tags.map((t) => normalize(t));
|
|
52
|
+
let score = 0;
|
|
53
|
+
score += synonymBoost(query, name);
|
|
54
|
+
if (name === q || name === q.replace(/ /g, '-')) {
|
|
55
|
+
score += 10000;
|
|
56
|
+
}
|
|
57
|
+
if (tokens.length === 1 && name === tokens[0]) {
|
|
58
|
+
score += 5000;
|
|
59
|
+
}
|
|
60
|
+
if (tokens.every((t) => name.includes(t) || nameNorm.includes(t))) {
|
|
61
|
+
score += 2000;
|
|
62
|
+
}
|
|
63
|
+
if (tagsNorm.some((t) => t === q)) {
|
|
64
|
+
score += 1500;
|
|
65
|
+
}
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
if (tagsNorm.some((t) => t === token))
|
|
68
|
+
score += 800;
|
|
69
|
+
if (name.includes(token))
|
|
70
|
+
score += 600;
|
|
71
|
+
if (tagsNorm.some((t) => t.includes(token)))
|
|
72
|
+
score += 300;
|
|
73
|
+
}
|
|
74
|
+
if (tokens.length === 1 && name.startsWith(tokens[0])) {
|
|
75
|
+
score += 400;
|
|
76
|
+
}
|
|
77
|
+
score += Math.max(0, 50 - name.length);
|
|
78
|
+
if (weight) {
|
|
79
|
+
if (icon.weights[weight])
|
|
80
|
+
score += 10;
|
|
81
|
+
else
|
|
82
|
+
score -= 1000;
|
|
83
|
+
}
|
|
84
|
+
return score;
|
|
85
|
+
}
|
|
86
|
+
export function searchIcons(index, query, options = {}) {
|
|
87
|
+
if (isSentenceQuery(query)) {
|
|
88
|
+
return {
|
|
89
|
+
error: 'Query looks like a full sentence. Use concise keywords instead, such as "cart", "user", or "settings".',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const limit = options.limit ?? 5;
|
|
93
|
+
const tokens = tokenize(query);
|
|
94
|
+
const weights = options.weight ? [options.weight] : ['Outline', 'Filled'];
|
|
95
|
+
const results = [];
|
|
96
|
+
for (const icon of index.icons) {
|
|
97
|
+
for (const weight of weights) {
|
|
98
|
+
if (!icon.weights[weight])
|
|
99
|
+
continue;
|
|
100
|
+
const score = scoreIcon(icon, query, tokens, weight);
|
|
101
|
+
if (score > 0) {
|
|
102
|
+
results.push({
|
|
103
|
+
name: icon.name,
|
|
104
|
+
weight,
|
|
105
|
+
category: icon.category,
|
|
106
|
+
tags: icon.tags,
|
|
107
|
+
score,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
results.sort((a, b) => b.score - a.score || a.name.length - b.name.length);
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const deduped = [];
|
|
115
|
+
for (const r of results) {
|
|
116
|
+
const key = `${r.name}:${r.weight}`;
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
deduped.push(r);
|
|
121
|
+
if (deduped.length >= limit)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
results: deduped,
|
|
126
|
+
instruction: 'Pick exactly one result from the list above and proceed with view_icon or apply_icon. Do not ask a person to choose.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function findIcon(index, name) {
|
|
130
|
+
return index.icons.find((i) => i.name === name);
|
|
131
|
+
}
|
|
132
|
+
export function listCategories(index) {
|
|
133
|
+
return index.categories;
|
|
134
|
+
}
|
package/core/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type IconWeight = 'Outline' | 'Filled';
|
|
2
|
+
export type Framework = 'react' | 'vue' | 'svelte' | 'html' | 'svg';
|
|
3
|
+
export interface IconWeightData {
|
|
4
|
+
code: string;
|
|
5
|
+
viewBox: string;
|
|
6
|
+
}
|
|
7
|
+
export interface IconEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
pascal: string;
|
|
10
|
+
category: string;
|
|
11
|
+
tags: string[];
|
|
12
|
+
weights: Partial<Record<IconWeight, IconWeightData>>;
|
|
13
|
+
}
|
|
14
|
+
export interface IconIndex {
|
|
15
|
+
icons: IconEntry[];
|
|
16
|
+
categories: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface SearchResult {
|
|
19
|
+
name: string;
|
|
20
|
+
weight: IconWeight;
|
|
21
|
+
category: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
score: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ApplyIconInput {
|
|
26
|
+
name: string;
|
|
27
|
+
weight: IconWeight;
|
|
28
|
+
framework: Framework;
|
|
29
|
+
size?: number;
|
|
30
|
+
color?: string;
|
|
31
|
+
componentName?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ApplyIconOutput {
|
|
34
|
+
importStatement: string;
|
|
35
|
+
usageSnippet: string;
|
|
36
|
+
}
|
package/core/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|