@infomind-ux/infoux-mcp 0.3.0 → 0.4.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/README.md +13 -0
- package/bin/icon-search.js +132 -0
- package/bin/server.js +162 -10
- package/data/icon-library.json +3937 -0
- package/data/icons.json +144 -72
- package/data/manifest.json +4 -4
- package/data/references/krds-components.md +6 -4
- package/data/snippets/icon.md +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@ INFOMIND UX팀의 퍼블리싱 기준(infoUX)을 MCP로 제공한다. 팀원이
|
|
|
11
11
|
| `get_contract` | infoUX 작업 컨트랙트 전문. UI 작업 시작 전에 한 번 읽는다 |
|
|
12
12
|
| `list_components` | 컴포넌트 카탈로그 (KRDS 28종 + infoUX 확장 — 푸터·모바일 메뉴·공지 띠·오류 페이지 등) |
|
|
13
13
|
| `get_component` | 컴포넌트 마크업 스니펫 + 접근성 요건 |
|
|
14
|
+
| `list_icons` | 아이콘 찾기. `query` 없이 부르면 세트 목록, 주면 구글 Material Symbols 전량을 한국어·영어로 찾아 「세트에 있음」과 「카탈로그에만 있음」으로 나눠 준다 |
|
|
15
|
+
| `get_icon` | 아이콘 마크업·표정·접근성 요건. 구글 이름(`shopping_cart`)으로 물어도 세트의 이름을 알려 준다. 카탈로그에만 있는 아이콘이면 마크업 대신 채택 방법을 준다 |
|
|
14
16
|
| `get_tokens` | 색상·폰트·브레이크포인트 토큰. `query`로 필터, `raw`로 tokens.css 원본 |
|
|
15
17
|
| `get_rules` | 코딩 규칙 R-01~R-27 (위반·준수 예시 포함) |
|
|
16
18
|
| `get_reference` | 접근성·금지패턴·Tailwind 매핑·HTML 시맨틱·사이트 유형 프로필 |
|
|
@@ -19,6 +21,17 @@ INFOMIND UX팀의 퍼블리싱 기준(infoUX)을 MCP로 제공한다. 팀원이
|
|
|
19
21
|
| `get_art_direction` | 프로필별 아트 디렉션 — 표현 등급 상세·타이포 페어링·팔레트 프리셋·한글 조판·안티패턴 색인 |
|
|
20
22
|
| `search_docs` | 어느 문서를 봐야 할지 모를 때 전체 검색 |
|
|
21
23
|
|
|
24
|
+
### 아이콘: 세트와 카탈로그
|
|
25
|
+
|
|
26
|
+
**세트**는 우리가 채택한 아이콘(번호가 붙어 바로 쓴다), **카탈로그**는 구글 Material Symbols 전량(3,900여 종, 한국어 이름·검색어 포함)이다.
|
|
27
|
+
카탈로그에만 있는 아이콘은 코드포인트·스프라이트가 없어 **채택하기 전에는 쓸 수 없다** — 그래서 `get_icon`은 마크업을 주지 않고
|
|
28
|
+
채택 방법(스튜디오 「세트에 넣기」 또는 `npm run icons:adopt`)을 알려 준다. 에이전트가 없는 이름으로 마크업을 쓰면
|
|
29
|
+
화면에 아무것도 안 나오고, SVG를 직접 붙이면 R-27 위반이다.
|
|
30
|
+
|
|
31
|
+
검색 코드(`bin/icon-search.js`)는 아이콘 스튜디오와 같은 파일의 복사본이다 — 사람이 찾는 결과와 AI가 찾는 결과가 같다.
|
|
32
|
+
카탈로그 색인(`data/icon-library.json`)은 영문 태그를 포함해 약 2MB(압축 후 약 0.6MB)다. 태그를 빼면 영어 동의어
|
|
33
|
+
(`purchase`·`padlock`·`envelope`)로는 찾을 수 없어 에이전트가 「없다」고 답하게 되므로 싣는다.
|
|
34
|
+
|
|
22
35
|
서버는 접속 시 **지시문**도 함께 넘긴다 — 사이트 유형 판정, 토큰 강제, 카탈로그 우선, 규칙 준수 순서가 에이전트에 자동으로 걸린다.
|
|
23
36
|
|
|
24
37
|
## 설치
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// 아이콘 카탈로그 검색 — 스튜디오 서버·CLI·MCP가 같은 점수를 쓴다.
|
|
2
|
+
//
|
|
3
|
+
// 이 파일은 mcp/bin/icon-search.js로 복사되어 나간다(npm run build:mcp). MCP 패키지는
|
|
4
|
+
// 이 저장소의 scripts/를 볼 수 없어서다. 둘이 어긋나면 AI가 찾는 결과와 사람이 스튜디오에서
|
|
5
|
+
// 찾는 결과가 달라진다 — check-harness가 두 파일이 같은지 지킨다. 고칠 때는 여기서만 고친다.
|
|
6
|
+
//
|
|
7
|
+
// 의존성이 없다. 색인(library.json)의 한 줄은 {m, n, c, p, ko, k, t} 꼴이다.
|
|
8
|
+
// m 구글 이름 n 우리 기본 이름(없으면 null) c 분류 p 인기도
|
|
9
|
+
// ko 한국어 이름 k 한국어 검색어 t 영문 태그
|
|
10
|
+
|
|
11
|
+
/** 칸마다 걸린 정도. 정확히 맞을수록, 한국어 이름에 맞을수록 높다. */
|
|
12
|
+
const W = {
|
|
13
|
+
koExact: 100, koStart: 80, koIncl: 60,
|
|
14
|
+
kExact: 70, kStart: 50, kIncl: 30,
|
|
15
|
+
nameExact: 90, nameSeg: 60, nameIncl: 30,
|
|
16
|
+
tagExact: 20, tagIncl: 8,
|
|
17
|
+
catIncl: 10
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 걸린 정도를 세 등급으로 묶는다. 등급이 다르면 무조건 높은 쪽이 앞이고,
|
|
22
|
+
* 같은 등급 안에서는 많이 쓰이는 쪽이 앞이다.
|
|
23
|
+
*
|
|
24
|
+
* 왜 이렇게 나누나: 「돋보기」를 치면 사람은 검색 아이콘을 찾는다. 그런데 「돋보기 도킹」이라는
|
|
25
|
+
* 이름의 아이콘은 이름이 「돋보기」로 시작해서 80점, 검색은 검색어 사전에서 정확히 맞아 70점이었다.
|
|
26
|
+
* 점수만 보면 인기 없는 쪽이 앞선다. 둘은 같은 등급(잘 맞는다)이므로 인기로 가려야 한다.
|
|
27
|
+
* 반대로 한국어 이름이 정확히 같은 것(장바구니)은 인기와 무관하게 맨 위여야 한다.
|
|
28
|
+
*/
|
|
29
|
+
const TIER = { exact: 90, good: 50, weak: 30 }
|
|
30
|
+
|
|
31
|
+
function tierOf(avg) {
|
|
32
|
+
if (avg >= TIER.exact) return 3
|
|
33
|
+
if (avg >= TIER.good) return 2
|
|
34
|
+
if (avg >= TIER.weak) return 1
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const norm = (s) => String(s || '').toLowerCase().trim()
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 아이콘을 찾는 자리에서 「아이콘」은 아무것도 가르지 않는다. 그런데 구글 태그에는 「find icon」처럼
|
|
42
|
+
* 「icon」이 박힌 것이 많아, 「cart icon」이라고 치면 장바구니와 상관없는 아이콘이 끼어든다.
|
|
43
|
+
*/
|
|
44
|
+
const NOISE_TERMS = new Set(['icon', 'icons', '아이콘'])
|
|
45
|
+
|
|
46
|
+
/** 검색어 한 마디가 아이콘 한 개에 얼마나 걸리나. 안 걸리면 0. */
|
|
47
|
+
function scoreTerm(icon, term, categoryLabel) {
|
|
48
|
+
let best = 0
|
|
49
|
+
let via = ''
|
|
50
|
+
const hit = (score, reason) => { if (score > best) { best = score; via = reason } }
|
|
51
|
+
|
|
52
|
+
// 이름(영어) — 구글 이름과 우리 이름 둘 다
|
|
53
|
+
for (const raw of [icon.m, icon.n]) {
|
|
54
|
+
const name = norm(raw)
|
|
55
|
+
if (!name) continue
|
|
56
|
+
const flat = name.replace(/[-_]/g, ' ')
|
|
57
|
+
if (name === term || flat === term) hit(W.nameExact, 'name')
|
|
58
|
+
else if (flat.split(' ').includes(term)) hit(W.nameSeg, 'name')
|
|
59
|
+
else if (flat.includes(term)) hit(W.nameIncl, 'name')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 한국어 이름과 검색어
|
|
63
|
+
const ko = norm(icon.ko)
|
|
64
|
+
if (ko) {
|
|
65
|
+
if (ko === term) hit(W.koExact, 'ko')
|
|
66
|
+
else if (ko.startsWith(term)) hit(W.koStart, 'ko')
|
|
67
|
+
else if (ko.includes(term)) hit(W.koIncl, 'ko')
|
|
68
|
+
}
|
|
69
|
+
for (const k of icon.k || []) {
|
|
70
|
+
const w = norm(k)
|
|
71
|
+
if (!w) continue
|
|
72
|
+
if (w === term) hit(W.kExact, 'k')
|
|
73
|
+
else if (w.startsWith(term)) hit(W.kStart, 'k')
|
|
74
|
+
else if (w.includes(term)) hit(W.kIncl, 'k')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 영문 태그 — 이름에 없는 말로 걸릴 때(purchase → shopping_cart)
|
|
78
|
+
for (const t of icon.t || []) {
|
|
79
|
+
if (t === term) hit(W.tagExact, 'tag')
|
|
80
|
+
else if (term.length >= 3 && t.includes(term)) hit(W.tagIncl, 'tag')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 분류 이름
|
|
84
|
+
if (categoryLabel && norm(categoryLabel).includes(term)) hit(W.catIncl, 'cat')
|
|
85
|
+
|
|
86
|
+
return { score: best, via }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 검색. 띄어 쓴 말은 모두 걸려야 한다(AND). 마디별 최고점의 평균으로 등급을 매기고,
|
|
91
|
+
* 같은 등급 안에서는 인기도(로그)로 가른다. 점수 = 등급 × 1000 + 평균 + 10 × log10(인기도+1).
|
|
92
|
+
*
|
|
93
|
+
* @param {{icons: object[], categories?: object[]}} library
|
|
94
|
+
* @param {string} query
|
|
95
|
+
* @param {{limit?: number, category?: string|null, only?: Set<string>|null}} opts
|
|
96
|
+
* only: 이 구글 이름들만 대상으로 삼는다(세트에 있는 것만 볼 때)
|
|
97
|
+
* @returns {{icon: object, score: number, via: string}[]}
|
|
98
|
+
*/
|
|
99
|
+
function search(library, query, opts = {}) {
|
|
100
|
+
const { limit = 60, category = null, only = null } = opts
|
|
101
|
+
const terms = norm(query).split(/\s+/).filter((t) => t && !NOISE_TERMS.has(t))
|
|
102
|
+
const labelOf = new Map((library.categories || []).map((c) => [c.id, c.label]))
|
|
103
|
+
|
|
104
|
+
const rows = []
|
|
105
|
+
for (const icon of library.icons) {
|
|
106
|
+
if (category && icon.c !== category) continue
|
|
107
|
+
if (only && !only.has(icon.m)) continue
|
|
108
|
+
|
|
109
|
+
if (terms.length === 0) {
|
|
110
|
+
rows.push({ icon, score: 0, via: '' })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
let sum = 0
|
|
114
|
+
let via = ''
|
|
115
|
+
let ok = true
|
|
116
|
+
for (const term of terms) {
|
|
117
|
+
const r = scoreTerm(icon, term, labelOf.get(icon.c))
|
|
118
|
+
if (r.score === 0) { ok = false; break }
|
|
119
|
+
sum += r.score
|
|
120
|
+
if (!via) via = r.via
|
|
121
|
+
}
|
|
122
|
+
if (!ok) continue
|
|
123
|
+
const avg = sum / terms.length
|
|
124
|
+
// 인기도는 로그로 누른다 — 0~18만을 0~53으로. 등급을 넘어서 뒤집지는 못한다(등급이 ×1000)
|
|
125
|
+
rows.push({ icon, score: tierOf(avg) * 1000 + avg + 10 * Math.log10((icon.p || 0) + 1), via })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
rows.sort((a, b) => b.score - a.score || (b.icon.p || 0) - (a.icon.p || 0) || a.icon.m.localeCompare(b.icon.m))
|
|
129
|
+
return rows.slice(0, limit)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { search, scoreTerm }
|
package/bin/server.js
CHANGED
|
@@ -19,6 +19,10 @@ const {
|
|
|
19
19
|
ListToolsRequestSchema
|
|
20
20
|
} = require('@modelcontextprotocol/sdk/types.js')
|
|
21
21
|
|
|
22
|
+
// 아이콘 검색은 아이콘 스튜디오·CLI와 같은 코드다(scripts/lib/icon-search.js의 복사본).
|
|
23
|
+
// 사람이 스튜디오에서 찾는 결과와 AI가 여기서 찾는 결과가 달라지지 않게 한다 — check-harness가 지킨다.
|
|
24
|
+
const { search: searchIcons } = require('./icon-search.js')
|
|
25
|
+
|
|
22
26
|
const DATA_DIR = path.resolve(__dirname, '..', 'data')
|
|
23
27
|
|
|
24
28
|
function readData(...segments) {
|
|
@@ -48,10 +52,12 @@ const INSTRUCTIONS = `INFOMIND UX팀의 HTML/CSS 퍼블리싱 기준(infoUX)을
|
|
|
48
52
|
3. 컴포넌트는 카탈로그를 먼저 본다. list_components → get_component 순으로 확인하고
|
|
49
53
|
기존 스니펫을 조합한다. 카탈로그 밖 컴포넌트는 임의 생성하지 않는다.
|
|
50
54
|
페이지·폼·위젯 설계나 컴포넌트 신규 생성처럼 절차가 정해진 작업은 get_workflow를 먼저 읽는다.
|
|
51
|
-
4. 아이콘도 카탈로그에서 가져온다. list_icons → get_icon 순으로 확인한다.
|
|
55
|
+
4. 아이콘도 카탈로그에서 가져온다. list_icons(query) → get_icon(name) 순으로 확인한다.
|
|
52
56
|
**아이콘 이름을 지어내지 않는다** — 목록에 없는 이름을 쓰면 화면에 아무것도 안 나온다.
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
구글 Material Symbols 전량을 한국어·영어로 찾을 수 있고, 결과는 둘로 나뉜다 —
|
|
58
|
+
「세트에 있음」은 바로 쓰고, 「카탈로그에만 있음」은 사용자가 세트에 넣어야(채택) 쓸 수 있다.
|
|
59
|
+
카탈로그에도 없으면 UX팀에 요청한다 (R-27). 세트에 없는 아이콘을 SVG로 직접 넣지 않는다.
|
|
60
|
+
장식용은 aria-hidden, 의미를 담으면 role="img"+aria-label을 붙인다.
|
|
55
61
|
5. 규칙 R-01~R-27을 지킨다. get_rules로 확인한다. BEM, 접근성, 금지 패턴이 여기 있다.
|
|
56
62
|
6. 간격·크기·타이포 스케일·반경·모션은 토큰이 아니라 CSS/Tailwind 직접값으로 쓴다.
|
|
57
63
|
7. 원칙이 충돌하면 get_reference("trade-off-rules")의 우선순위를 따른다. 접근성이 1순위다.
|
|
@@ -94,12 +100,13 @@ const TOOLS = [
|
|
|
94
100
|
{
|
|
95
101
|
name: 'list_icons',
|
|
96
102
|
description:
|
|
97
|
-
'
|
|
98
|
-
'
|
|
103
|
+
'아이콘을 찾는다. 아이콘이 필요하면 **반드시 먼저 확인한다** — 목록에 없는 이름을 지어내면 ' +
|
|
104
|
+
'화면에 아무것도 안 나온다. query 없이 부르면 세트 목록, query를 주면 구글 Material Symbols 전량을 ' +
|
|
105
|
+
'한국어·영어로 찾아 「세트에 있음」(바로 쓴다)과 「카탈로그에만 있음」(채택해야 쓴다)으로 나눠 준다.',
|
|
99
106
|
inputSchema: {
|
|
100
107
|
type: 'object',
|
|
101
108
|
properties: {
|
|
102
|
-
query: { type: 'string', description: '
|
|
109
|
+
query: { type: 'string', description: '찾는 말 (예: 장바구니, 영수증, arrow, calendar). 띄어 쓰면 모두 걸려야 한다' }
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
112
|
},
|
|
@@ -107,11 +114,11 @@ const TOOLS = [
|
|
|
107
114
|
name: 'get_icon',
|
|
108
115
|
description:
|
|
109
116
|
'아이콘 하나의 마크업과 접근성 요건을 반환한다. 장식용인지 의미를 담는지에 따라 ' +
|
|
110
|
-
'aria 처리가 달라지므로 그대로 복사해 쓴다.',
|
|
117
|
+
'aria 처리가 달라지므로 그대로 복사해 쓴다. 카탈로그에만 있는 아이콘이면 마크업 대신 채택 방법을 알려 준다.',
|
|
111
118
|
inputSchema: {
|
|
112
119
|
type: 'object',
|
|
113
120
|
properties: {
|
|
114
|
-
name: { type: 'string', description: '아이콘 이름 (예: search, chevron-right, calendar)' }
|
|
121
|
+
name: { type: 'string', description: '아이콘 이름 (예: search, chevron-right, calendar). 구글 이름(shopping_cart)도 받는다' }
|
|
115
122
|
},
|
|
116
123
|
required: ['name']
|
|
117
124
|
}
|
|
@@ -242,10 +249,92 @@ function loadIconLedger() {
|
|
|
242
249
|
}
|
|
243
250
|
}
|
|
244
251
|
|
|
252
|
+
// 카탈로그 색인(구글 Material Symbols 전량). 없으면 null — 세트 목록만으로 예전처럼 답한다.
|
|
253
|
+
let iconLibrary
|
|
254
|
+
function loadIconLibrary() {
|
|
255
|
+
if (iconLibrary !== undefined) return iconLibrary
|
|
256
|
+
try {
|
|
257
|
+
iconLibrary = JSON.parse(readData('icon-library.json'))
|
|
258
|
+
} catch {
|
|
259
|
+
iconLibrary = null
|
|
260
|
+
}
|
|
261
|
+
return iconLibrary
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 구글 이름 → 세트에서 쓰는 우리 이름. 이름이 달라져도(keyboard_arrow_up → chevron-up) 이어 준다. */
|
|
265
|
+
function adoptedByMaterial(ledger) {
|
|
266
|
+
const map = new Map()
|
|
267
|
+
for (const [name, meta] of Object.entries(ledger.icons)) if (meta.material) map.set(meta.material, name)
|
|
268
|
+
return map
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 세트·카탈로그를 한꺼번에 찾아 「바로 쓴다」와 「채택해야 쓴다」로 나눠 보여 준다. */
|
|
272
|
+
function searchAllIcons(query, ledger, library) {
|
|
273
|
+
const taken = adoptedByMaterial(ledger)
|
|
274
|
+
const hits = searchIcons(library, query, { limit: Number.MAX_SAFE_INTEGER })
|
|
275
|
+
|
|
276
|
+
const inSet = []
|
|
277
|
+
const onlyCatalog = []
|
|
278
|
+
const seen = new Set()
|
|
279
|
+
for (const { icon } of hits) {
|
|
280
|
+
const ours = taken.get(icon.m)
|
|
281
|
+
if (ours) {
|
|
282
|
+
inSet.push({ name: ours, ko: icon.ko, material: icon.m })
|
|
283
|
+
seen.add(ours)
|
|
284
|
+
} else {
|
|
285
|
+
onlyCatalog.push(icon)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 세트에는 있는데 카탈로그 색인에 없는 것(자체 제작·씨앗 밖)은 이름과 검색어로 따로 찾는다
|
|
290
|
+
const needle = String(query).toLowerCase()
|
|
291
|
+
for (const [name, meta] of Object.entries(ledger.icons)) {
|
|
292
|
+
if (seen.has(name) || meta.material) continue
|
|
293
|
+
const words = (meta.keywords || []).map(k => String(k).toLowerCase())
|
|
294
|
+
if (name.includes(needle) || words.some(k => k.includes(needle))) inSet.push({ name, ko: (meta.keywords || [])[0] || '' })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const lines = ['# infoUX 아이콘 검색', '']
|
|
298
|
+
lines.push(`"${query}" — 세트 ${inSet.length}종 · 카탈로그에만 ${onlyCatalog.length}종 (카탈로그 ${library.icons.length.toLocaleString('en-US')}종 가운데)`, '')
|
|
299
|
+
|
|
300
|
+
if (inSet.length > 0) {
|
|
301
|
+
lines.push('## 세트에 있음 — 바로 쓴다', '')
|
|
302
|
+
for (const i of inSet.slice(0, 30)) lines.push(`- \`${i.name}\`${i.ko ? ` — ${i.ko}` : ''}`)
|
|
303
|
+
if (inSet.length > 30) lines.push(`- …외 ${inSet.length - 30}종 (검색어를 좁힌다)`)
|
|
304
|
+
lines.push('', 'get_icon(name)으로 마크업을 가져온다.', '')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (onlyCatalog.length > 0) {
|
|
308
|
+
lines.push('## 카탈로그에만 있음 — 채택해야 쓴다', '')
|
|
309
|
+
for (const i of onlyCatalog.slice(0, 20)) {
|
|
310
|
+
const name = i.n || (i.s ? `이름 제안 ${i.s}` : '이름을 정해야 함')
|
|
311
|
+
lines.push(`- \`${i.m}\`${i.ko ? ` — ${i.ko}` : ''} (${i.n ? `채택하면 ${name}` : name})`)
|
|
312
|
+
}
|
|
313
|
+
if (onlyCatalog.length > 20) lines.push(`- …외 ${onlyCatalog.length - 20}종 (검색어를 좁힌다)`)
|
|
314
|
+
lines.push(
|
|
315
|
+
'',
|
|
316
|
+
'이 아이콘들은 아직 세트에 없어 코드포인트·스프라이트가 없다. **지금 마크업에 쓰면 화면에 아무것도 안 나온다.**',
|
|
317
|
+
'get_icon(구글 이름)이 채택 방법을 알려 준다. 사용자에게 채택을 요청하고, SVG를 직접 붙이지 않는다 (R-27).',
|
|
318
|
+
''
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (inSet.length === 0 && onlyCatalog.length === 0) {
|
|
323
|
+
lines.push(
|
|
324
|
+
'세트에도 카탈로그에도 없다. **이름을 지어내지 않는다.**',
|
|
325
|
+
'다른 말로 한 번 더 찾아 본다(동의어·영어 이름·더 짧은 단어). 그래도 없으면 UX팀에 제작을 요청한다 (R-27).'
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
return text(lines.join('\n'))
|
|
329
|
+
}
|
|
330
|
+
|
|
245
331
|
function listIcons({ query } = {}) {
|
|
246
332
|
const ledger = loadIconLedger()
|
|
247
333
|
if (!ledger) return text('아이콘 카탈로그가 이 번들에 없다. npm run build:mcp로 다시 만든다.')
|
|
248
334
|
|
|
335
|
+
const library = loadIconLibrary()
|
|
336
|
+
if (query && String(query).trim() && library) return searchAllIcons(String(query).trim(), ledger, library)
|
|
337
|
+
|
|
249
338
|
const all = Object.entries(ledger.icons).map(([name, meta]) => ({ name, ...meta }))
|
|
250
339
|
const needle = query ? String(query).toLowerCase() : null
|
|
251
340
|
// 한국어로도 찾는다 — 「달력」·「즐겨찾기」·「찾아오는길」. 이름이 영어라
|
|
@@ -275,7 +364,7 @@ function listIcons({ query } = {}) {
|
|
|
275
364
|
|
|
276
365
|
const withFill = all.filter((i) => (i.variants || []).includes('fill')).length
|
|
277
366
|
const lines = ['# infoUX 아이콘 카탈로그', '']
|
|
278
|
-
lines.push(query ? `"${query}" 검색 — ${rows.length}종` :
|
|
367
|
+
lines.push(query ? `"${query}" 검색 — ${rows.length}종` : `세트 ${rows.length}종.`, '')
|
|
279
368
|
if (needle) {
|
|
280
369
|
// 이름에 없는 말로 걸린 것은 왜 걸렸는지 밝힌다 — 엉뚱한 결과처럼 보이지 않게
|
|
281
370
|
const via = rows
|
|
@@ -292,9 +381,12 @@ function listIcons({ query } = {}) {
|
|
|
292
381
|
'채울 면이 없는 형태(돋보기·화살표 등)에는 만들지 않는다. **어느 아이콘에 무엇이 있는지는 get_icon이 알려 준다.**',
|
|
293
382
|
'',
|
|
294
383
|
'get_icon(name)으로 마크업을 가져온다.',
|
|
384
|
+
library
|
|
385
|
+
? `세트에 없는 아이콘은 list_icons(query)로 카탈로그 ${library.icons.length.toLocaleString('en-US')}종에서 찾는다 — 채택하면 쓸 수 있다.`
|
|
386
|
+
: '',
|
|
295
387
|
'**목록에 없는 이름을 쓰지 않는다** — 화면에 아무것도 안 나온다. 필요하면 UX팀에 요청한다 (R-27).'
|
|
296
388
|
)
|
|
297
|
-
return text(lines.join('\n'))
|
|
389
|
+
return text(lines.filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n'))
|
|
298
390
|
}
|
|
299
391
|
|
|
300
392
|
/**
|
|
@@ -333,12 +425,72 @@ function iconVariantSection(name, meta) {
|
|
|
333
425
|
]
|
|
334
426
|
}
|
|
335
427
|
|
|
428
|
+
/**
|
|
429
|
+
* 카탈로그에만 있는 아이콘 — 마크업을 주지 않고 채택 방법을 알려 준다.
|
|
430
|
+
*
|
|
431
|
+
* 마크업을 만들어 주면 AI는 그대로 쓴다. 그런데 이 이름의 클래스·스프라이트·코드포인트가
|
|
432
|
+
* 프로젝트에 없어 화면에 아무것도 안 나온다. SVG를 직접 붙이는 것도 R-27 위반이다.
|
|
433
|
+
*/
|
|
434
|
+
function catalogOnlyIcon(row, library) {
|
|
435
|
+
const label = library.categories?.find(c => c.id === row.c)?.label
|
|
436
|
+
const ours = row.n || row.s
|
|
437
|
+
const lines = [
|
|
438
|
+
`# ${row.m} — 카탈로그에만 있음`,
|
|
439
|
+
'',
|
|
440
|
+
`${row.ko ? `한국어 이름 ${row.ko} · ` : ''}${label ? `분류 ${label} · ` : ''}구글 Material Symbols. **이 세트에는 아직 없다** (코드포인트·스프라이트 없음).`,
|
|
441
|
+
...(row.k && row.k.length > 0 ? ['', `이 아이콘을 부르는 말 — ${row.k.join(' · ')}`] : []),
|
|
442
|
+
'',
|
|
443
|
+
'## 쓰려면 채택해야 한다',
|
|
444
|
+
''
|
|
445
|
+
]
|
|
446
|
+
if (row.n) {
|
|
447
|
+
lines.push(`채택하면 \`${row.n}\`(으)로 쓴다.`)
|
|
448
|
+
} else {
|
|
449
|
+
lines.push(`이름 규칙에 맞지 않아 이름을 정해야 한다 — ${row.x}.${row.s ? ` 제안: \`${row.s}\`.` : ''}`)
|
|
450
|
+
}
|
|
451
|
+
lines.push(
|
|
452
|
+
'',
|
|
453
|
+
'1. 아이콘 스튜디오 → 찾기 → 카탈로그 전체 → 이 아이콘을 눌러 「세트에 넣기」',
|
|
454
|
+
`2. 저장소가 있으면: \`npm run icons:adopt -- ${row.m}${row.n ? '' : ` --as ${ours || '<이름>'}`} --build\``,
|
|
455
|
+
`3. 채택해 저장소에 반영된 뒤에 get_icon("${ours || '<채택한 이름>'}")이 마크업을 준다.`,
|
|
456
|
+
'',
|
|
457
|
+
'**채택하기 전에는 이 이름으로 마크업을 쓰지 않는다** — 화면에 아무것도 안 나온다.',
|
|
458
|
+
'SVG를 직접 붙여 넣는 것도 안 된다 (R-27). 사용자에게 채택을 요청한다.'
|
|
459
|
+
)
|
|
460
|
+
return text(lines.join('\n'))
|
|
461
|
+
}
|
|
462
|
+
|
|
336
463
|
function getIcon(name) {
|
|
337
464
|
const ledger = loadIconLedger()
|
|
338
465
|
if (!ledger) return text('아이콘 카탈로그가 이 번들에 없다. npm run build:mcp로 다시 만든다.')
|
|
339
466
|
|
|
340
467
|
const meta = ledger.icons[name]
|
|
341
468
|
if (!meta) {
|
|
469
|
+
const library = loadIconLibrary()
|
|
470
|
+
// 구글 이름(shopping_cart)이나 하이픈으로 바꾼 꼴(shopping-cart)로 물어도 알아듣는다
|
|
471
|
+
const key = String(name || '').trim().toLowerCase()
|
|
472
|
+
const row = library?.icons.find(i => i.m === key || i.m === key.replace(/-/g, '_'))
|
|
473
|
+
if (row) {
|
|
474
|
+
const ours = adoptedByMaterial(ledger).get(row.m)
|
|
475
|
+
if (ours) {
|
|
476
|
+
const inner = getIcon(ours).content[0].text
|
|
477
|
+
return text(`"${name}"은(는) 구글 이름이다. 이 세트에서 쓰는 이름 — \`${ours}\`\n\n${inner}`)
|
|
478
|
+
}
|
|
479
|
+
return catalogOnlyIcon(row, library)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 가까운 후보 — 카탈로그까지 훑는다. 세트에 있는 것과 채택해야 하는 것을 구분해 보인다
|
|
483
|
+
if (library) {
|
|
484
|
+
const taken = adoptedByMaterial(ledger)
|
|
485
|
+
const label = (icon) => (taken.has(icon.m) ? `${taken.get(icon.m)}(세트)` : `${icon.m}(카탈로그에만)`)
|
|
486
|
+
const words = key.split(/[-_\s]+/).filter(Boolean)
|
|
487
|
+
// 마디를 모두 만족하는 것을 먼저, 없으면 마디 하나씩 — 철자가 틀린 이름이나 지어낸 이름도 단서를 준다
|
|
488
|
+
let near = searchIcons(library, words.join(' '), { limit: 8 }).map(({ icon }) => label(icon))
|
|
489
|
+
for (let w = 0; near.length === 0 && w < words.length; w += 1) {
|
|
490
|
+
near = searchIcons(library, words[w], { limit: 8 }).map(({ icon }) => label(icon))
|
|
491
|
+
}
|
|
492
|
+
if (near.length > 0) return notFound(`아이콘 "${name}"`, near)
|
|
493
|
+
}
|
|
342
494
|
const near = Object.keys(ledger.icons).filter(n => n.includes(String(name).split('-')[0])).slice(0, 8)
|
|
343
495
|
return notFound(`아이콘 "${name}"`, near.length > 0 ? near : Object.keys(ledger.icons).slice(0, 12))
|
|
344
496
|
}
|