@supericons/mcp 0.4.6
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/CHANGELOG.md +78 -0
- package/auth.js +69 -0
- package/converter.js +8 -0
- package/generated/mcp-output-locales.json +12436 -0
- package/generated/motion-lab-baseline.json +886 -0
- package/hosted-search-client.js +198 -0
- package/index.js +1240 -0
- package/material-export.js +174 -0
- package/mcp-output-localization.js +132 -0
- package/motion-lab-client.js +347 -0
- package/motion-lab.js +21 -0
- package/package.json +63 -0
- package/public/cjk-search-terms.json +63474 -0
- package/public/multilingual-search-aliases.json +4307 -0
- package/public/product-facts.json +25 -0
- package/recommend-icons.js +707 -0
- package/remote-server.js +465 -0
- package/runtime/cjk-search-core.js +82 -0
- package/runtime/converter-workflow.js +593 -0
- package/runtime/generated-search-intent-rules.js +1190 -0
- package/runtime/icon-semantic-aliases.js +330 -0
- package/runtime/icon-taxonomy-seed.js +461 -0
- package/runtime/public-metadata-sanitizer.js +171 -0
- package/runtime/search-intent-core.js +130 -0
- package/search.js +375 -0
- package/semantic-registry.js +212 -0
- package/server.json +27 -0
- package/telemetry.js +85 -0
- package/variant-support.js +236 -0
- package/workflow-access.js +65 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { GENERATED_INTENT_RULES } from './generated-search-intent-rules.js';
|
|
2
|
+
|
|
3
|
+
function unique(values = []) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const output = [];
|
|
6
|
+
|
|
7
|
+
for (const value of values) {
|
|
8
|
+
const normalized = normalizeIntentText(value);
|
|
9
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
10
|
+
seen.add(normalized);
|
|
11
|
+
output.push(normalized);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return output;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeIntentText(value) {
|
|
18
|
+
return String(value || '')
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[_:]+/g, ' ')
|
|
21
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
22
|
+
.replace(/-/g, ' ')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function tokenizeIntentText(value) {
|
|
28
|
+
const normalized = normalizeIntentText(value);
|
|
29
|
+
return normalized ? normalized.split(' ') : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compileRule(rule) {
|
|
33
|
+
return {
|
|
34
|
+
variants: rule.variants || [],
|
|
35
|
+
prefer: (rule.prefer || []).map((source) => new RegExp(source, 'i')),
|
|
36
|
+
avoid: (rule.avoid || []).map((source) => new RegExp(source, 'i')),
|
|
37
|
+
avoidUnless: rule.avoidUnless || rule.avoid_unless || [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const INTENT_RULES = Object.freeze(
|
|
42
|
+
Object.fromEntries(
|
|
43
|
+
Object.entries(GENERATED_INTENT_RULES).map(([term, rule]) => [term, compileRule(rule)]),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function getCandidateText(candidate = {}) {
|
|
48
|
+
return [
|
|
49
|
+
candidate.icon_id,
|
|
50
|
+
candidate.id,
|
|
51
|
+
candidate.name,
|
|
52
|
+
candidate.label,
|
|
53
|
+
candidate.source_name,
|
|
54
|
+
candidate.library,
|
|
55
|
+
candidate.source_library,
|
|
56
|
+
].filter(Boolean).join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasOverrideToken(queryTokens, rule) {
|
|
60
|
+
if (!rule?.avoidUnless?.length) return false;
|
|
61
|
+
return rule.avoidUnless.some((token) => queryTokens.has(normalizeIntentText(token)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildSearchIntentProfile(query) {
|
|
65
|
+
const normalized = normalizeIntentText(query);
|
|
66
|
+
const tokens = tokenizeIntentText(normalized);
|
|
67
|
+
const tokenSet = new Set(tokens);
|
|
68
|
+
const activeRules = [];
|
|
69
|
+
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
const rule = INTENT_RULES[token];
|
|
72
|
+
if (rule) activeRules.push({ token, rule });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
query: normalized,
|
|
77
|
+
tokens,
|
|
78
|
+
tokenSet,
|
|
79
|
+
activeRules,
|
|
80
|
+
expanded: activeRules.length > 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildIntentQueryVariants(query, options = {}) {
|
|
85
|
+
const maxVariants = Math.max(1, Math.min(12, Number(options.maxVariants || 8)));
|
|
86
|
+
const normalizedQuery = normalizeIntentText(query);
|
|
87
|
+
const baseQuery = normalizeIntentText(options.baseQuery || normalizedQuery);
|
|
88
|
+
const profile = buildSearchIntentProfile(normalizedQuery);
|
|
89
|
+
const variants = [];
|
|
90
|
+
|
|
91
|
+
if (baseQuery) variants.push(baseQuery);
|
|
92
|
+
if (normalizedQuery && normalizedQuery !== baseQuery) variants.push(normalizedQuery);
|
|
93
|
+
|
|
94
|
+
for (const { rule } of profile.activeRules) {
|
|
95
|
+
variants.push(...rule.variants);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return unique(variants).slice(0, maxVariants);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getIntentCandidateAdjustment(candidate = {}, intentProfile = buildSearchIntentProfile('')) {
|
|
102
|
+
const candidateText = getCandidateText(candidate);
|
|
103
|
+
const normalizedCandidateText = normalizeIntentText(candidateText);
|
|
104
|
+
const rawCandidateText = String(candidateText || '');
|
|
105
|
+
let boost = 0;
|
|
106
|
+
let penalty = 0;
|
|
107
|
+
const reasons = [];
|
|
108
|
+
|
|
109
|
+
for (const { token, rule } of intentProfile.activeRules || []) {
|
|
110
|
+
for (const pattern of rule.prefer || []) {
|
|
111
|
+
if (pattern.test(rawCandidateText) || pattern.test(normalizedCandidateText)) {
|
|
112
|
+
boost += 18;
|
|
113
|
+
reasons.push(`prefer:${token}`);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!hasOverrideToken(intentProfile.tokenSet, rule)) {
|
|
119
|
+
for (const pattern of rule.avoid || []) {
|
|
120
|
+
if (pattern.test(rawCandidateText) || pattern.test(normalizedCandidateText)) {
|
|
121
|
+
penalty += 28;
|
|
122
|
+
reasons.push(`avoid:${token}`);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { boost, penalty, reasons };
|
|
130
|
+
}
|
package/search.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local fallback search only.
|
|
3
|
+
* Do not treat this file as the production ranking engine.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
expandCjkQuery,
|
|
11
|
+
normalizeCjkSearchText,
|
|
12
|
+
} from './runtime/cjk-search-core.js';
|
|
13
|
+
import { createIconSemanticAliasMap } from './runtime/icon-semantic-aliases.js';
|
|
14
|
+
import { createIconTaxonomyMap } from './runtime/icon-taxonomy-seed.js';
|
|
15
|
+
import {
|
|
16
|
+
compareVariantPreference,
|
|
17
|
+
getConceptKeyForIcon,
|
|
18
|
+
iconMatchesRequestedStyle,
|
|
19
|
+
normalizeRequestedStyle,
|
|
20
|
+
} from './variant-support.js';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const iconSemanticAliasMap = createIconSemanticAliasMap();
|
|
24
|
+
const iconTaxonomyMap = createIconTaxonomyMap();
|
|
25
|
+
const packagedCjkTermsPath = join(__dirname, 'public', 'cjk-search-terms.json');
|
|
26
|
+
const repoCjkTermsPath = join(__dirname, '..', 'data', 'i18n', 'cjk-search-terms.json');
|
|
27
|
+
const cjkTermsPath = existsSync(packagedCjkTermsPath) ? packagedCjkTermsPath : repoCjkTermsPath;
|
|
28
|
+
const cjkSearchTerms = existsSync(cjkTermsPath)
|
|
29
|
+
? JSON.parse(readFileSync(cjkTermsPath, 'utf8')).terms || []
|
|
30
|
+
: [];
|
|
31
|
+
const packagedMultilingualAliasesPath = join(__dirname, 'public', 'multilingual-search-aliases.json');
|
|
32
|
+
const repoMultilingualAliasesPath = join(__dirname, '..', 'data', 'i18n', 'multilingual-search-aliases.json');
|
|
33
|
+
const multilingualAliasesPath = existsSync(packagedMultilingualAliasesPath)
|
|
34
|
+
? packagedMultilingualAliasesPath
|
|
35
|
+
: repoMultilingualAliasesPath;
|
|
36
|
+
const multilingualSearchAliases = existsSync(multilingualAliasesPath)
|
|
37
|
+
? JSON.parse(readFileSync(multilingualAliasesPath, 'utf8')).aliases || []
|
|
38
|
+
: [];
|
|
39
|
+
const multilingualExpansionTerms = [...cjkSearchTerms, ...multilingualSearchAliases];
|
|
40
|
+
const iconSearchMetadataCache = new WeakMap();
|
|
41
|
+
|
|
42
|
+
/** Inline Levenshtein distance (capped early for performance) */
|
|
43
|
+
function editDistance(a, b) {
|
|
44
|
+
if (Math.abs(a.length - b.length) > 2) return 99;
|
|
45
|
+
const m = a.length, n = b.length;
|
|
46
|
+
const prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
47
|
+
const curr = new Array(n + 1);
|
|
48
|
+
for (let i = 1; i <= m; i++) {
|
|
49
|
+
curr[0] = i;
|
|
50
|
+
for (let j = 1; j <= n; j++) {
|
|
51
|
+
curr[j] = a[i - 1] === b[j - 1]
|
|
52
|
+
? prev[j - 1]
|
|
53
|
+
: 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
|
|
54
|
+
}
|
|
55
|
+
prev.splice(0, n + 1, ...curr);
|
|
56
|
+
}
|
|
57
|
+
return prev[n];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeSemanticText(value) {
|
|
61
|
+
return normalizeCjkSearchText(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tokenizeSemanticText(value) {
|
|
65
|
+
const normalized = normalizeSemanticText(value);
|
|
66
|
+
return normalized ? normalized.split(' ') : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function iconKey(icon) {
|
|
70
|
+
return `${icon.lib}:${icon.id}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getIconJobRank(icon) {
|
|
74
|
+
return iconTaxonomyMap.get(iconKey(icon))?.rank ?? Number.MAX_SAFE_INTEGER;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getIconSemanticAliases(icon) {
|
|
78
|
+
return iconSemanticAliasMap.get(iconKey(icon)) || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getIconSearchMetadata(icon) {
|
|
82
|
+
const cached = iconSearchMetadataCache.get(icon);
|
|
83
|
+
if (cached) return cached;
|
|
84
|
+
|
|
85
|
+
const name = normalizeSemanticText(icon.name);
|
|
86
|
+
const id = normalizeSemanticText(icon.id);
|
|
87
|
+
const fullId = normalizeSemanticText(iconKey(icon));
|
|
88
|
+
const lowerName = String(icon.name || '').toLowerCase();
|
|
89
|
+
const lowerId = String(icon.id || '').toLowerCase();
|
|
90
|
+
const tokens = new Set([
|
|
91
|
+
...tokenizeSemanticText(icon.name),
|
|
92
|
+
...tokenizeSemanticText(icon.id),
|
|
93
|
+
...tokenizeSemanticText(iconKey(icon)),
|
|
94
|
+
]);
|
|
95
|
+
const segments = lowerId.split(/[-_]/).concat(lowerName.split(/[\s\-_]/));
|
|
96
|
+
const aliases = (getIconSemanticAliases(icon) || [])
|
|
97
|
+
.map((alias) => {
|
|
98
|
+
const normalized = normalizeSemanticText(alias);
|
|
99
|
+
return normalized
|
|
100
|
+
? { normalized, tokens: new Set(tokenizeSemanticText(normalized)) }
|
|
101
|
+
: null;
|
|
102
|
+
})
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
const metadata = {
|
|
105
|
+
name,
|
|
106
|
+
id,
|
|
107
|
+
fullId,
|
|
108
|
+
lowerName,
|
|
109
|
+
lowerId,
|
|
110
|
+
tokens,
|
|
111
|
+
segments,
|
|
112
|
+
aliases,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
iconSearchMetadataCache.set(icon, metadata);
|
|
116
|
+
return metadata;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getDirectSearchScore(icon, normalizedQuery, queryWords) {
|
|
120
|
+
if (!normalizedQuery) return 0;
|
|
121
|
+
|
|
122
|
+
const { name, id, fullId, tokens } = getIconSearchMetadata(icon);
|
|
123
|
+
|
|
124
|
+
if (name === normalizedQuery || id === normalizedQuery || fullId === normalizedQuery) {
|
|
125
|
+
return 320;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (normalizedQuery.length > 2 && (
|
|
129
|
+
name.includes(normalizedQuery)
|
|
130
|
+
|| id.includes(normalizedQuery)
|
|
131
|
+
|| fullId.includes(normalizedQuery)
|
|
132
|
+
)) {
|
|
133
|
+
return 250;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (queryWords.length > 0 && queryWords.every((word) => tokens.has(word))) {
|
|
137
|
+
return 190;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (queryWords.length > 0 && queryWords.every((word) => (
|
|
141
|
+
name.includes(word) || id.includes(word) || fullId.includes(word)
|
|
142
|
+
))) {
|
|
143
|
+
return 150;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getCuratedAliasScore(icon, normalizedQuery, queryWords) {
|
|
150
|
+
if (!normalizedQuery) return 0;
|
|
151
|
+
|
|
152
|
+
const { aliases } = getIconSearchMetadata(icon);
|
|
153
|
+
if (!aliases?.length) return 0;
|
|
154
|
+
|
|
155
|
+
let bestScore = 0;
|
|
156
|
+
|
|
157
|
+
for (const alias of aliases) {
|
|
158
|
+
const { normalized: normalizedAlias, tokens: aliasTokens } = alias;
|
|
159
|
+
if (!normalizedAlias) continue;
|
|
160
|
+
|
|
161
|
+
if (normalizedAlias === normalizedQuery) {
|
|
162
|
+
bestScore = Math.max(bestScore, 420);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (normalizedQuery.length > 3 && normalizedAlias.includes(normalizedQuery)) {
|
|
167
|
+
bestScore = Math.max(bestScore, 360);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (queryWords.length > 1 && queryWords.every((word) => aliasTokens.has(word))) {
|
|
172
|
+
bestScore = Math.max(bestScore, 320);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (queryWords.length === 1 && aliasTokens.has(queryWords[0])) {
|
|
177
|
+
bestScore = Math.max(bestScore, 260);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (queryWords.length > 0 && queryWords.every((word) => normalizedAlias.includes(word))) {
|
|
182
|
+
bestScore = Math.max(bestScore, 220);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return bestScore;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Expand a single search word into a set of matching terms */
|
|
190
|
+
function expandSingleTerm(word, synonyms) {
|
|
191
|
+
const terms = new Set([word]);
|
|
192
|
+
|
|
193
|
+
// 1. Direct key match
|
|
194
|
+
if (synonyms[word]) synonyms[word].forEach(t => terms.add(t));
|
|
195
|
+
|
|
196
|
+
// 2. Reverse lookup (word is a value in some group)
|
|
197
|
+
for (const [key, values] of Object.entries(synonyms)) {
|
|
198
|
+
if (values.some(v => v === word || v.split(' ').includes(word))) {
|
|
199
|
+
terms.add(key);
|
|
200
|
+
values.forEach(t => terms.add(t));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Prefix matching (word is a prefix of a synonym key, min 3 chars)
|
|
205
|
+
if (word.length >= 3) {
|
|
206
|
+
for (const [key, values] of Object.entries(synonyms)) {
|
|
207
|
+
if (key.startsWith(word) && key !== word) {
|
|
208
|
+
terms.add(key);
|
|
209
|
+
values.forEach(t => terms.add(t));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Plural/suffix normalization
|
|
215
|
+
if (terms.size === 1) {
|
|
216
|
+
const stripped = word
|
|
217
|
+
.replace(/ings?$/, '')
|
|
218
|
+
.replace(/ations?$/, 'ate')
|
|
219
|
+
.replace(/es$/, '')
|
|
220
|
+
.replace(/s$/, '');
|
|
221
|
+
if (stripped !== word && stripped.length > 2) {
|
|
222
|
+
if (synonyms[stripped]) synonyms[stripped].forEach(t => terms.add(t));
|
|
223
|
+
for (const [key, values] of Object.entries(synonyms)) {
|
|
224
|
+
if (key === stripped || values.includes(stripped)) {
|
|
225
|
+
terms.add(key);
|
|
226
|
+
values.forEach(t => terms.add(t));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 5. Fuzzy typo tolerance (edit distance <= 1, only for queries > 4 chars)
|
|
233
|
+
if (terms.size === 1 && word.length > 4) {
|
|
234
|
+
for (const key of Object.keys(synonyms)) {
|
|
235
|
+
if (editDistance(word, key) <= 1) {
|
|
236
|
+
terms.add(key);
|
|
237
|
+
synonyms[key].forEach(t => terms.add(t));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Filter out 2-char expansions (keep original word)
|
|
243
|
+
const result = [...terms].filter(t => t === word || t.length > 2);
|
|
244
|
+
return result.slice(0, 20);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Expand a full search query, returning array of term-sets for AND matching */
|
|
248
|
+
function expandSearchTerms(query, synonyms) {
|
|
249
|
+
const words = query.trim().split(/\s+/).filter(Boolean);
|
|
250
|
+
return words.map(w => expandSingleTerm(w, synonyms));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Search icons with tiered results.
|
|
255
|
+
* @param {string} query - Search term
|
|
256
|
+
* @param {Array} icons - Array of icon objects
|
|
257
|
+
* @param {Object} synonyms - Synonym map
|
|
258
|
+
* @param {Object} options - { library, limit }
|
|
259
|
+
* @returns {Array} Matched icons (direct first, then synonym matches)
|
|
260
|
+
*/
|
|
261
|
+
export function searchIcons(query, icons, synonyms, options = {}) {
|
|
262
|
+
const { library, limit = 20, style = 'any' } = options;
|
|
263
|
+
const cjkExpansion = expandCjkQuery(query, {
|
|
264
|
+
locale: options.locale,
|
|
265
|
+
terms: multilingualExpansionTerms,
|
|
266
|
+
});
|
|
267
|
+
const queryVariants = cjkExpansion.variants.length > 0 ? cjkExpansion.variants : [query];
|
|
268
|
+
const hasExpandedCjk = cjkExpansion.matched.length > 0 && queryVariants.length > 1;
|
|
269
|
+
|
|
270
|
+
if (hasExpandedCjk) {
|
|
271
|
+
const merged = [];
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
|
|
274
|
+
for (const variant of queryVariants.slice(1)) {
|
|
275
|
+
const results = searchIconsForSingleQuery(variant, icons, synonyms, {
|
|
276
|
+
library,
|
|
277
|
+
limit: Math.max(limit * 2, 20),
|
|
278
|
+
style,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
for (const icon of results) {
|
|
282
|
+
const key = iconKey(icon);
|
|
283
|
+
if (seen.has(key)) continue;
|
|
284
|
+
seen.add(key);
|
|
285
|
+
merged.push(icon);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return merged.slice(0, Math.max(1, limit));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return searchIconsForSingleQuery(query, icons, synonyms, options);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function searchIconsForSingleQuery(query, icons, synonyms, options = {}) {
|
|
296
|
+
const { library, limit = 20, style = 'any' } = options;
|
|
297
|
+
const normalizedStyle = normalizeRequestedStyle(style);
|
|
298
|
+
|
|
299
|
+
// Library filter
|
|
300
|
+
let filtered = icons;
|
|
301
|
+
if (library) {
|
|
302
|
+
filtered = filtered.filter(icon => icon.lib === library);
|
|
303
|
+
}
|
|
304
|
+
filtered = filtered.filter((icon) => iconMatchesRequestedStyle(icon, normalizedStyle));
|
|
305
|
+
|
|
306
|
+
if (!query || !query.trim()) {
|
|
307
|
+
return filtered.slice(0, limit);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const normalizedQuery = normalizeSemanticText(query);
|
|
311
|
+
if (!normalizedQuery) return [];
|
|
312
|
+
const queryWords = tokenizeSemanticText(query);
|
|
313
|
+
const termSets = expandSearchTerms(normalizedQuery, synonyms);
|
|
314
|
+
|
|
315
|
+
// Helper: check if icon matches a set of term-sets
|
|
316
|
+
const iconMatchesTermSets = (icon, sets) => {
|
|
317
|
+
const { lowerName: name, lowerId: id, segments } = getIconSearchMetadata(icon);
|
|
318
|
+
return sets.every(terms =>
|
|
319
|
+
terms.some(term => {
|
|
320
|
+
if (term.length <= 3) return segments.some(s => s === term);
|
|
321
|
+
return name.includes(term) || id.includes(term);
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Tier 1: direct query words match
|
|
327
|
+
const tier1 = filtered
|
|
328
|
+
.map((icon) => ({
|
|
329
|
+
icon,
|
|
330
|
+
aliasScore: getCuratedAliasScore(icon, normalizedQuery, queryWords),
|
|
331
|
+
directScore: getDirectSearchScore(icon, normalizedQuery, queryWords),
|
|
332
|
+
}))
|
|
333
|
+
.filter(({ aliasScore, directScore }) => aliasScore > 0 || directScore > 0)
|
|
334
|
+
.sort((a, b) => {
|
|
335
|
+
if (b.aliasScore !== a.aliasScore) return b.aliasScore - a.aliasScore;
|
|
336
|
+
if (b.directScore !== a.directScore) return b.directScore - a.directScore;
|
|
337
|
+
|
|
338
|
+
const rankDiff = getIconJobRank(a.icon) - getIconJobRank(b.icon);
|
|
339
|
+
if (rankDiff !== 0) return rankDiff;
|
|
340
|
+
|
|
341
|
+
return a.icon.name.localeCompare(b.icon.name);
|
|
342
|
+
})
|
|
343
|
+
.map(({ icon }) => icon);
|
|
344
|
+
const tier1Keys = new Set(tier1.map((i) => iconKey(i)));
|
|
345
|
+
|
|
346
|
+
// Tier 2: synonym expansion matches
|
|
347
|
+
const tier2 = filtered.filter(icon =>
|
|
348
|
+
!tier1Keys.has(iconKey(icon)) && iconMatchesTermSets(icon, termSets)
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const merged = [...tier1, ...tier2];
|
|
352
|
+
if (normalizedStyle !== 'any') {
|
|
353
|
+
return merged.slice(0, limit);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const selected = new Map();
|
|
357
|
+
const orderedKeys = [];
|
|
358
|
+
|
|
359
|
+
for (const icon of merged) {
|
|
360
|
+
const conceptKey = getConceptKeyForIcon(icon) || iconKey(icon);
|
|
361
|
+
const existing = selected.get(conceptKey);
|
|
362
|
+
|
|
363
|
+
if (!existing) {
|
|
364
|
+
selected.set(conceptKey, icon);
|
|
365
|
+
orderedKeys.push(conceptKey);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (compareVariantPreference(existing, icon, normalizedStyle) > 0) {
|
|
370
|
+
selected.set(conceptKey, icon);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return orderedKeys.map((key) => selected.get(key)).slice(0, limit);
|
|
375
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { getBaseSemanticIdsForVariant } from './variant-support.js';
|
|
4
|
+
|
|
5
|
+
const PUBLIC_SEMANTIC_FIELDS = Object.freeze([
|
|
6
|
+
'label',
|
|
7
|
+
'source_name',
|
|
8
|
+
'depicts',
|
|
9
|
+
'semantic_tags',
|
|
10
|
+
'synonyms',
|
|
11
|
+
'use_when',
|
|
12
|
+
'avoid_when',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function normalizeText(value) {
|
|
16
|
+
return String(value || '')
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[_:]+/g, ' ')
|
|
19
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
20
|
+
.replace(/-/g, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tokenize(value) {
|
|
26
|
+
const normalized = normalizeText(value);
|
|
27
|
+
return normalized ? normalized.split(' ') : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildPossibleRegistryIds(library, id) {
|
|
31
|
+
return getBaseSemanticIdsForVariant({ library, id });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getPrimaryRegistryId(iconOrLibrary, maybeId) {
|
|
35
|
+
if (typeof iconOrLibrary === 'object' && iconOrLibrary) {
|
|
36
|
+
return `${iconOrLibrary.lib}:${iconOrLibrary.id}`;
|
|
37
|
+
}
|
|
38
|
+
return `${iconOrLibrary}:${maybeId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPossibleRegistryIds(iconOrLibrary, maybeId) {
|
|
42
|
+
if (typeof iconOrLibrary === 'object' && iconOrLibrary) {
|
|
43
|
+
return buildPossibleRegistryIds(iconOrLibrary.lib, iconOrLibrary.id);
|
|
44
|
+
}
|
|
45
|
+
return buildPossibleRegistryIds(iconOrLibrary, maybeId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadSemanticRegistryRecords(dataDir) {
|
|
49
|
+
const recordsPath = join(dataDir, 'registry-records.json');
|
|
50
|
+
if (!existsSync(recordsPath)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsed = JSON.parse(readFileSync(recordsPath, 'utf8'));
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createSemanticRegistryMap(records) {
|
|
59
|
+
return new Map(records.map((record) => [record.icon_id, record]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildPublicSemanticPayload(record) {
|
|
63
|
+
if (!record) return null;
|
|
64
|
+
|
|
65
|
+
const payload = {};
|
|
66
|
+
for (const field of PUBLIC_SEMANTIC_FIELDS) {
|
|
67
|
+
if (!(field in record)) continue;
|
|
68
|
+
const value = record[field];
|
|
69
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
70
|
+
if (typeof value === 'string' && value.trim().length === 0) continue;
|
|
71
|
+
payload[field] = value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Object.keys(payload).length > 0 ? payload : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getSemanticRecordForIcon(semanticMap, iconOrLibrary, maybeId) {
|
|
78
|
+
for (const registryId of getPossibleRegistryIds(iconOrLibrary, maybeId)) {
|
|
79
|
+
const record = semanticMap.get(registryId);
|
|
80
|
+
if (record) return record;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function attachSemanticPayload(target, semanticMap, iconOrLibrary, maybeId) {
|
|
86
|
+
const semanticRecord = getSemanticRecordForIcon(semanticMap, iconOrLibrary, maybeId);
|
|
87
|
+
const semantic = buildPublicSemanticPayload(semanticRecord);
|
|
88
|
+
return semantic ? { ...target, semantic } : target;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function scoreSemanticAlignment(query, semanticRecord) {
|
|
92
|
+
if (!semanticRecord) return 0;
|
|
93
|
+
|
|
94
|
+
const queryTokens = tokenize(query);
|
|
95
|
+
if (queryTokens.length === 0) return 0;
|
|
96
|
+
|
|
97
|
+
const weightedSources = [
|
|
98
|
+
{ value: semanticRecord.label, weight: 6 },
|
|
99
|
+
{ value: semanticRecord.source_name, weight: 5 },
|
|
100
|
+
{ value: semanticRecord.depicts, weight: 4 },
|
|
101
|
+
{ value: semanticRecord.use_when, weight: 3 },
|
|
102
|
+
{ value: semanticRecord.avoid_when, weight: 2 },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
let score = 0;
|
|
106
|
+
|
|
107
|
+
for (const tag of semanticRecord.semantic_tags || []) {
|
|
108
|
+
const normalized = normalizeText(tag);
|
|
109
|
+
for (const token of queryTokens) {
|
|
110
|
+
if (normalized === token) score += 8;
|
|
111
|
+
else if (normalized.includes(token)) score += 5;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const synonym of semanticRecord.synonyms || []) {
|
|
116
|
+
const normalized = normalizeText(synonym);
|
|
117
|
+
for (const token of queryTokens) {
|
|
118
|
+
if (normalized === token) score += 7;
|
|
119
|
+
else if (normalized.includes(token)) score += 4;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const source of weightedSources) {
|
|
124
|
+
const normalized = normalizeText(source.value);
|
|
125
|
+
if (!normalized) continue;
|
|
126
|
+
for (const token of queryTokens) {
|
|
127
|
+
if (normalized === token) score += source.weight + 2;
|
|
128
|
+
else if (normalized.includes(token)) score += source.weight;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return score;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function chooseSemanticCandidate(query, icons, semanticMap) {
|
|
136
|
+
const scored = icons
|
|
137
|
+
.map((icon, index) => {
|
|
138
|
+
const semanticRecord = getSemanticRecordForIcon(semanticMap, icon);
|
|
139
|
+
return {
|
|
140
|
+
icon,
|
|
141
|
+
index,
|
|
142
|
+
semanticRecord,
|
|
143
|
+
score: scoreSemanticAlignment(query, semanticRecord),
|
|
144
|
+
};
|
|
145
|
+
})
|
|
146
|
+
.filter((entry) => entry.score > 0)
|
|
147
|
+
.sort((left, right) => {
|
|
148
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
149
|
+
return left.index - right.index;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return scored[0] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function searchSemanticRegistryRecords(query, semanticMap, options = {}) {
|
|
156
|
+
const { limit = 20, minimumScore = 12 } = options;
|
|
157
|
+
const normalizedLimit = Math.max(1, limit);
|
|
158
|
+
|
|
159
|
+
const scored = [...semanticMap.values()]
|
|
160
|
+
.map((record) => ({
|
|
161
|
+
record,
|
|
162
|
+
score: scoreSemanticAlignment(query, record),
|
|
163
|
+
}))
|
|
164
|
+
.filter((entry) => entry.score >= minimumScore)
|
|
165
|
+
.sort((left, right) => {
|
|
166
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
167
|
+
return left.record.icon_id.localeCompare(right.record.icon_id);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return scored.slice(0, normalizedLimit);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function mergeSemanticMatchesIntoIcons(query, baselineIcons, searchableIcons, semanticMap, options = {}) {
|
|
174
|
+
const { limit = 20, minimumScore = 12 } = options;
|
|
175
|
+
const normalizedLimit = Math.max(1, limit);
|
|
176
|
+
const baseline = Array.isArray(baselineIcons) ? baselineIcons : [];
|
|
177
|
+
const searchable = Array.isArray(searchableIcons) ? searchableIcons : [];
|
|
178
|
+
|
|
179
|
+
const byId = new Map();
|
|
180
|
+
for (const icon of searchable) {
|
|
181
|
+
for (const registryId of getPossibleRegistryIds(icon)) {
|
|
182
|
+
if (!byId.has(registryId)) {
|
|
183
|
+
byId.set(registryId, icon);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const merged = [];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
|
|
190
|
+
const semanticMatches = searchSemanticRegistryRecords(query, semanticMap, {
|
|
191
|
+
limit: normalizedLimit,
|
|
192
|
+
minimumScore,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
for (const match of semanticMatches) {
|
|
196
|
+
const icon = byId.get(match.record.icon_id);
|
|
197
|
+
if (!icon) continue;
|
|
198
|
+
const registryId = getPrimaryRegistryId(icon);
|
|
199
|
+
if (seen.has(registryId)) continue;
|
|
200
|
+
merged.push(icon);
|
|
201
|
+
seen.add(registryId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const icon of baseline) {
|
|
205
|
+
const registryId = getPrimaryRegistryId(icon);
|
|
206
|
+
if (seen.has(registryId)) continue;
|
|
207
|
+
merged.push(icon);
|
|
208
|
+
seen.add(registryId);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return merged.slice(0, normalizedLimit);
|
|
212
|
+
}
|