@hashicorp/design-system-mcp 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 +375 -0
- package/README.md +83 -0
- package/dist/catalog/loader.js +99 -0
- package/dist/catalog/normalize.js +7 -0
- package/dist/catalog/project-root.js +47 -0
- package/dist/index.js +107 -0
- package/dist/prompts/index.js +10 -0
- package/dist/prompts/types.js +5 -0
- package/dist/resources/completions.js +30 -0
- package/dist/resources/components/constants.js +6 -0
- package/dist/resources/components/get-component-by-name.js +54 -0
- package/dist/resources/components/get-components.js +33 -0
- package/dist/resources/components/index.js +11 -0
- package/dist/resources/components/utils.js +39 -0
- package/dist/resources/define-resource.js +35 -0
- package/dist/resources/hds-icons/constants.js +6 -0
- package/dist/resources/hds-icons/get-icon-by-name.js +48 -0
- package/dist/resources/hds-icons/get-icons.js +33 -0
- package/dist/resources/hds-icons/index.js +11 -0
- package/dist/resources/hds-icons/utils.js +26 -0
- package/dist/resources/index.js +23 -0
- package/dist/resources/responses.js +34 -0
- package/dist/resources/tokens/constants.js +2 -0
- package/dist/resources/tokens/get-token-by-key.js +51 -0
- package/dist/resources/tokens/get-tokens.js +33 -0
- package/dist/resources/tokens/index.js +11 -0
- package/dist/resources/tokens/utils.js +15 -0
- package/dist/resources/types.js +5 -0
- package/dist/resources/uri.js +15 -0
- package/dist/stores/components/index.js +114 -0
- package/dist/stores/components/lookup.js +55 -0
- package/dist/stores/components/schema.js +37 -0
- package/dist/stores/docs/constants.js +163 -0
- package/dist/stores/docs/index.js +322 -0
- package/dist/stores/docs/lookup.js +148 -0
- package/dist/stores/docs/schema.js +60 -0
- package/dist/stores/docs/search-index.js +102 -0
- package/dist/stores/docs/tokenize.js +52 -0
- package/dist/stores/hds-icons/index.js +115 -0
- package/dist/stores/hds-icons/lookup.js +43 -0
- package/dist/stores/hds-icons/schema.js +31 -0
- package/dist/stores/rank.js +129 -0
- package/dist/stores/tokens/index.js +105 -0
- package/dist/stores/tokens/lookup.js +65 -0
- package/dist/stores/tokens/schema.js +42 -0
- package/dist/stores/types.js +5 -0
- package/dist/tools/components/constants.js +15 -0
- package/dist/tools/components/get-component.js +98 -0
- package/dist/tools/components/index.js +11 -0
- package/dist/tools/components/search-components.js +75 -0
- package/dist/tools/components/utils.js +42 -0
- package/dist/tools/constants.js +7 -0
- package/dist/tools/define-tool.js +17 -0
- package/dist/tools/docs/constants.js +33 -0
- package/dist/tools/docs/index.js +8 -0
- package/dist/tools/docs/read-doc.js +101 -0
- package/dist/tools/docs/search-docs.js +143 -0
- package/dist/tools/docs/utils.js +82 -0
- package/dist/tools/hds-icons/constants.js +5 -0
- package/dist/tools/hds-icons/index.js +7 -0
- package/dist/tools/hds-icons/search-icons.js +123 -0
- package/dist/tools/index.js +19 -0
- package/dist/tools/output-schema.js +11 -0
- package/dist/tools/responses.js +52 -0
- package/dist/tools/search.js +36 -0
- package/dist/tools/tokens/constants.js +5 -0
- package/dist/tools/tokens/index.js +7 -0
- package/dist/tools/tokens/search-tokens.js +144 -0
- package/dist/tools/types.js +5 -0
- package/dist/types.js +5 -0
- package/docs-catalog.json +39778 -0
- package/package.json +62 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
// record shapes the store hands out, plus the filter and snippet rules applied to them
|
|
6
|
+
import { normalizeLookupValue } from "../../catalog/normalize.js";
|
|
7
|
+
import { SNIPPET_MAX_LENGTH } from "./constants.js";
|
|
8
|
+
import { tokenize } from "./tokenize.js";
|
|
9
|
+
// a route with no leading or trailing slash, matching the `docsPath` in the component catalog
|
|
10
|
+
export const normalizeRoute = (value) => {
|
|
11
|
+
return normalizeLookupValue(value).replace(/^\/+|\/+$/g, "");
|
|
12
|
+
};
|
|
13
|
+
export const toPageRecord = (page) => {
|
|
14
|
+
return {
|
|
15
|
+
route: page.route,
|
|
16
|
+
section: page.section,
|
|
17
|
+
title: page.title,
|
|
18
|
+
url: page.url,
|
|
19
|
+
...(page.caption === undefined ? {} : { caption: page.caption }),
|
|
20
|
+
...(page.description === undefined
|
|
21
|
+
? {}
|
|
22
|
+
: { description: page.description }),
|
|
23
|
+
keywords: page.keywords ?? [],
|
|
24
|
+
related: page.related ?? [],
|
|
25
|
+
...(page.links === undefined ? {} : { links: page.links }),
|
|
26
|
+
...(page.status === undefined ? {} : { status: page.status }),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export const toChunkRecord = (chunk, page) => {
|
|
30
|
+
return {
|
|
31
|
+
id: chunk.id,
|
|
32
|
+
route: page.route,
|
|
33
|
+
section: page.section,
|
|
34
|
+
pageTitle: page.title,
|
|
35
|
+
pageUrl: page.url,
|
|
36
|
+
url: chunk.url,
|
|
37
|
+
...(chunk.tab === undefined ? {} : { tab: chunk.tab }),
|
|
38
|
+
headingTrail: chunk.headingTrail ?? [],
|
|
39
|
+
...(chunk.level === undefined ? {} : { level: chunk.level }),
|
|
40
|
+
content: chunk.content,
|
|
41
|
+
text: chunk.text,
|
|
42
|
+
symbols: chunk.symbols ?? [],
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export const getHeadingPath = (chunk) => {
|
|
46
|
+
return [
|
|
47
|
+
chunk.pageTitle,
|
|
48
|
+
...(chunk.tab === undefined ? [] : [chunk.tab]),
|
|
49
|
+
...chunk.headingTrail,
|
|
50
|
+
];
|
|
51
|
+
};
|
|
52
|
+
export const getChunkHeading = (chunk) => {
|
|
53
|
+
return chunk.headingTrail.at(-1);
|
|
54
|
+
};
|
|
55
|
+
// the words a page is called by: its title, plus the terminal segment of its route
|
|
56
|
+
export const getPageNameTerms = (page) => {
|
|
57
|
+
const terminalSegment = page.route.split("/").at(-1) ?? page.route;
|
|
58
|
+
return new Set([...tokenize(page.title), ...tokenize(terminalSegment)]);
|
|
59
|
+
};
|
|
60
|
+
export const getAncestorHeadings = (chunk) => {
|
|
61
|
+
return chunk.headingTrail.slice(0, -1);
|
|
62
|
+
};
|
|
63
|
+
export const matchesDocsPath = (route, docsPath) => {
|
|
64
|
+
return route === docsPath || route.startsWith(`${docsPath}/`);
|
|
65
|
+
};
|
|
66
|
+
export const matchesFilters = (chunk, filters) => {
|
|
67
|
+
if (filters.section !== undefined &&
|
|
68
|
+
normalizeLookupValue(chunk.section) !== filters.section) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (filters.tab !== undefined &&
|
|
72
|
+
(chunk.tab === undefined || normalizeLookupValue(chunk.tab) !== filters.tab)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (filters.docsPath !== undefined &&
|
|
76
|
+
!matchesDocsPath(normalizeRoute(chunk.route), filters.docsPath)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
const SENTENCE_BOUNDARY_PATTERN = /(?<=[.!?])\s+/;
|
|
82
|
+
const ELLIPSIS = "…";
|
|
83
|
+
const countMatches = (sentence, queryTerms) => {
|
|
84
|
+
let matches = 0;
|
|
85
|
+
for (const term of tokenize(sentence)) {
|
|
86
|
+
if (queryTerms.has(term)) {
|
|
87
|
+
matches += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return matches;
|
|
91
|
+
};
|
|
92
|
+
const scoreSentence = (sentence, queryTerms) => {
|
|
93
|
+
const tokenCount = tokenize(sentence).length;
|
|
94
|
+
if (tokenCount === 0)
|
|
95
|
+
return 0;
|
|
96
|
+
return countMatches(sentence, queryTerms) / Math.sqrt(tokenCount);
|
|
97
|
+
};
|
|
98
|
+
export const buildSnippet = (text, queryTerms, budget = SNIPPET_MAX_LENGTH) => {
|
|
99
|
+
const trimmed = text.trim();
|
|
100
|
+
if (trimmed.length <= budget) {
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
const sentences = trimmed.split(SENTENCE_BOUNDARY_PATTERN);
|
|
104
|
+
if (sentences.length === 1) {
|
|
105
|
+
return `${trimmed.slice(0, budget).trimEnd()}${ELLIPSIS}`;
|
|
106
|
+
}
|
|
107
|
+
const scores = sentences.map((sentence) => scoreSentence(sentence, queryTerms));
|
|
108
|
+
let seedIndex = 0;
|
|
109
|
+
for (const [index, score] of scores.entries()) {
|
|
110
|
+
if (score > scores[seedIndex])
|
|
111
|
+
seedIndex = index;
|
|
112
|
+
}
|
|
113
|
+
const seed = sentences[seedIndex];
|
|
114
|
+
if (seed.length > budget) {
|
|
115
|
+
const prefix = seedIndex > 0 ? ELLIPSIS : "";
|
|
116
|
+
return `${prefix}${seed.slice(0, budget - prefix.length).trimEnd()}${ELLIPSIS}`;
|
|
117
|
+
}
|
|
118
|
+
let start = seedIndex;
|
|
119
|
+
let end = seedIndex;
|
|
120
|
+
let length = seed.length;
|
|
121
|
+
while (true) {
|
|
122
|
+
const previous = start > 0 ? start - 1 : null;
|
|
123
|
+
const next = end < sentences.length - 1 ? end + 1 : null;
|
|
124
|
+
if (previous === null && next === null) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const takePrevious = next === null || (previous !== null && scores[previous] >= scores[next]);
|
|
128
|
+
const candidate = takePrevious ? previous : next;
|
|
129
|
+
if (candidate === null) {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
const candidateLength = sentences[candidate].length + 1;
|
|
133
|
+
if (length + candidateLength > budget) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
length += candidateLength;
|
|
137
|
+
if (takePrevious) {
|
|
138
|
+
start = candidate;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
end = candidate;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const window = sentences.slice(start, end + 1).join(" ");
|
|
145
|
+
const prefix = start > 0 ? ELLIPSIS : "";
|
|
146
|
+
const suffix = end < sentences.length - 1 ? ELLIPSIS : "";
|
|
147
|
+
return `${prefix}${window}${suffix}`;
|
|
148
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SITE_BASE_URL } from "./constants.js";
|
|
7
|
+
const siteUrlSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.startsWith(SITE_BASE_URL, `must be a ${SITE_BASE_URL} url`);
|
|
10
|
+
const docsChunkSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
id: z.string().min(1),
|
|
13
|
+
url: siteUrlSchema,
|
|
14
|
+
// readable markdown, with the <Doc::*> and <Hds::*> usage examples left intact
|
|
15
|
+
content: z.string(),
|
|
16
|
+
// the same passage as `content` stripped to prose (for search scoring)
|
|
17
|
+
text: z.string(),
|
|
18
|
+
tab: z.string().min(1).optional(),
|
|
19
|
+
// ancestor headings first, the chunk's own heading last; absent on a tab preamble
|
|
20
|
+
headingTrail: z.array(z.string()).optional(),
|
|
21
|
+
level: z.number().int().optional(),
|
|
22
|
+
// identifiers harvested from the chunk's demo code, such as `@isFullWidth`
|
|
23
|
+
symbols: z.array(z.string()).optional(),
|
|
24
|
+
})
|
|
25
|
+
.catchall(z.any());
|
|
26
|
+
const docsPageLinksSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
github: z.string().min(1).optional(),
|
|
29
|
+
figma: z.string().min(1).optional(),
|
|
30
|
+
})
|
|
31
|
+
.catchall(z.any());
|
|
32
|
+
const docsPageStatusSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
updated: z.string().min(1).optional(),
|
|
35
|
+
deprecated: z.string().min(1).optional(),
|
|
36
|
+
})
|
|
37
|
+
.catchall(z.any());
|
|
38
|
+
const docsPageSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
route: z.string().min(1),
|
|
41
|
+
section: z.string().min(1),
|
|
42
|
+
title: z.string().min(1),
|
|
43
|
+
url: siteUrlSchema,
|
|
44
|
+
caption: z.string().optional(),
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
keywords: z.array(z.string()).optional(),
|
|
47
|
+
related: z.array(z.string()).optional(),
|
|
48
|
+
links: docsPageLinksSchema.optional(),
|
|
49
|
+
status: docsPageStatusSchema.optional(),
|
|
50
|
+
chunks: z.array(docsChunkSchema),
|
|
51
|
+
})
|
|
52
|
+
.catchall(z.any());
|
|
53
|
+
export const docsCatalogSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
// when this snapshot of the docs site was bundled
|
|
56
|
+
bundledAt: z.string().min(1),
|
|
57
|
+
siteBaseUrl: z.literal(SITE_BASE_URL),
|
|
58
|
+
pages: z.record(z.string().min(1), docsPageSchema),
|
|
59
|
+
})
|
|
60
|
+
.catchall(z.any());
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
// a local BM25 index; scoring never leaves this process
|
|
6
|
+
// https://lucene.apache.org/core/9_12_3/core/org/apache/lucene/search/similarities/BM25Similarity.html
|
|
7
|
+
import { BM25_B, BM25_K1 } from "./constants.js";
|
|
8
|
+
import { tokenize } from "./tokenize.js";
|
|
9
|
+
const toFieldText = (value) => {
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
return Array.isArray(value) ? value.join(" ") : value;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export const buildBm25Index = (documents) => {
|
|
18
|
+
const documentLengths = new Float64Array(documents.length);
|
|
19
|
+
const postings = new Map();
|
|
20
|
+
let totalLength = 0;
|
|
21
|
+
for (const [documentIndex, fields] of documents.entries()) {
|
|
22
|
+
const termFrequencies = new Map();
|
|
23
|
+
let documentLength = 0;
|
|
24
|
+
for (const field of fields) {
|
|
25
|
+
if (field.weight <= 0) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const term of tokenize(toFieldText(field.value))) {
|
|
29
|
+
termFrequencies.set(term, (termFrequencies.get(term) ?? 0) + field.weight);
|
|
30
|
+
documentLength += field.weight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
documentLengths[documentIndex] = documentLength;
|
|
34
|
+
totalLength += documentLength;
|
|
35
|
+
for (const [term, frequency] of termFrequencies) {
|
|
36
|
+
const termPostings = postings.get(term);
|
|
37
|
+
if (termPostings === undefined) {
|
|
38
|
+
postings.set(term, [{ documentIndex, frequency }]);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
termPostings.push({ documentIndex, frequency });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
documentCount: documents.length,
|
|
47
|
+
averageLength: documents.length === 0 ? 0 : totalLength / documents.length,
|
|
48
|
+
documentLengths,
|
|
49
|
+
postings,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
// inverse document frequency, the one measure of how much a term narrows the corpus down
|
|
53
|
+
const getIdf = (documentCount, documentFrequency) => {
|
|
54
|
+
return Math.log(1 + (documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
|
|
55
|
+
};
|
|
56
|
+
export const scoreBm25 = (index, queryTerms) => {
|
|
57
|
+
const scores = new Float64Array(index.documentCount);
|
|
58
|
+
const matchedTermsByDocument = new Map();
|
|
59
|
+
if (index.documentCount === 0 || index.averageLength === 0) {
|
|
60
|
+
return { scores, matchedTermsByDocument };
|
|
61
|
+
}
|
|
62
|
+
for (const term of queryTerms) {
|
|
63
|
+
const termPostings = index.postings.get(term);
|
|
64
|
+
if (termPostings === undefined) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const idf = getIdf(index.documentCount, termPostings.length);
|
|
68
|
+
for (const { documentIndex, frequency } of termPostings) {
|
|
69
|
+
const normalizedLength = index.documentLengths[documentIndex] / index.averageLength;
|
|
70
|
+
const denominator = frequency + BM25_K1 * (1 - BM25_B + BM25_B * normalizedLength);
|
|
71
|
+
scores[documentIndex] +=
|
|
72
|
+
(idf * (frequency * (BM25_K1 + 1))) / denominator;
|
|
73
|
+
const matched = matchedTermsByDocument.get(documentIndex);
|
|
74
|
+
if (matched === undefined) {
|
|
75
|
+
matchedTermsByDocument.set(documentIndex, new Set([term]));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
matched.add(term);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { scores, matchedTermsByDocument };
|
|
83
|
+
};
|
|
84
|
+
export const hasTerm = (index, term) => {
|
|
85
|
+
return index.postings.has(term);
|
|
86
|
+
};
|
|
87
|
+
// how completely a query and a name are the same set of words, as a Jaccard ratio
|
|
88
|
+
// https://www.ibm.com/think/topics/jaccard-similarity
|
|
89
|
+
export const getNameMatchRatio = (queryTerms, nameTerms) => {
|
|
90
|
+
if (queryTerms.size === 0 || nameTerms.size === 0) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
let shared = 0;
|
|
94
|
+
for (const term of nameTerms) {
|
|
95
|
+
if (queryTerms.has(term))
|
|
96
|
+
shared += 1;
|
|
97
|
+
}
|
|
98
|
+
if (shared === 0) {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
return shared / (queryTerms.size + nameTerms.size - shared);
|
|
102
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { STOPWORDS } from "./constants.js";
|
|
6
|
+
// the single tokenizer both the index and every query run through
|
|
7
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
8
|
+
// version strings are the point of the version-history tab, so they survive as whole tokens
|
|
9
|
+
const VERSION_PATTERN = /\d+\.\d+(?:\.\d+)?/g;
|
|
10
|
+
const NON_ALPHANUMERIC_PATTERN = /[^A-Za-z0-9]+/;
|
|
11
|
+
const CAMEL_BOUNDARY_PATTERN = /[a-z0-9][A-Z]/;
|
|
12
|
+
const CAMEL_SPLIT_PATTERN = /(?<=[a-z0-9])(?=[A-Z])/;
|
|
13
|
+
const NAMESPACE_TOKEN = "hds";
|
|
14
|
+
const NAMESPACE_PREFIX_PATTERN = /\bhds(?:::|[-_])(?=[A-Za-z0-9])/gi;
|
|
15
|
+
const pushToken = (tokens, candidate) => {
|
|
16
|
+
if (candidate.length < MIN_TOKEN_LENGTH) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
else if (STOPWORDS.has(candidate)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
tokens.push(candidate);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export const tokenize = (value) => {
|
|
27
|
+
const tokens = [];
|
|
28
|
+
const withoutNamespace = value.replace(NAMESPACE_PREFIX_PATTERN, "");
|
|
29
|
+
const withoutVersions = withoutNamespace.replace(VERSION_PATTERN, (version) => {
|
|
30
|
+
tokens.push(version);
|
|
31
|
+
return " ";
|
|
32
|
+
});
|
|
33
|
+
for (const rawToken of withoutVersions.split(NON_ALPHANUMERIC_PATTERN)) {
|
|
34
|
+
if (rawToken.length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
pushToken(tokens, rawToken.toLowerCase());
|
|
37
|
+
if (!CAMEL_BOUNDARY_PATTERN.test(rawToken)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const parts = rawToken.split(CAMEL_SPLIT_PATTERN);
|
|
41
|
+
for (const [index, part] of parts.entries()) {
|
|
42
|
+
if (index === 0 && part.toLowerCase() === NAMESPACE_TOKEN) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
pushToken(tokens, part.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return tokens;
|
|
49
|
+
};
|
|
50
|
+
export const tokenizeQuery = (value) => {
|
|
51
|
+
return [...new Set(tokenize(value))];
|
|
52
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { normalizeLookupValue } from "../../catalog/normalize.js";
|
|
6
|
+
import { DEFAULT_CATALOG_SOURCE, createCatalogLoader, } from "../../catalog/loader.js";
|
|
7
|
+
import { getIconLookupKeys, toIconRecord } from "./lookup.js";
|
|
8
|
+
import { iconCatalogSchema } from "./schema.js";
|
|
9
|
+
import { searchRanked } from "../rank.js";
|
|
10
|
+
const toIconSummary = (icon) => {
|
|
11
|
+
return {
|
|
12
|
+
iconName: icon.iconName,
|
|
13
|
+
description: icon.description,
|
|
14
|
+
category: icon.category,
|
|
15
|
+
sizes: icon.sizes,
|
|
16
|
+
hasMapping: icon.hasMapping,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const toSearchBlob = (icon) => {
|
|
20
|
+
const mappings = icon.variants
|
|
21
|
+
.map((variant) => variant.mapping ?? "")
|
|
22
|
+
.join(" ");
|
|
23
|
+
const fileNames = icon.variants.map((variant) => variant.fileName).join(" ");
|
|
24
|
+
return [icon.iconName, icon.description, icon.category, mappings, fileNames]
|
|
25
|
+
.join(" ")
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
};
|
|
28
|
+
// the `@name` value and the per-size file names; the description keywords stay in the blob so
|
|
29
|
+
// `warning` still reaches `alert-triangle` without outranking an icon actually named for it
|
|
30
|
+
const toRankable = (icon) => {
|
|
31
|
+
return {
|
|
32
|
+
identities: [
|
|
33
|
+
normalizeLookupValue(icon.iconName),
|
|
34
|
+
...icon.variants.map((variant) => normalizeLookupValue(variant.fileName)),
|
|
35
|
+
],
|
|
36
|
+
blob: toSearchBlob(icon),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export const parseIconCatalog = (value) => {
|
|
40
|
+
return iconCatalogSchema.parse(value);
|
|
41
|
+
};
|
|
42
|
+
export const createIconCatalogStore = (catalog, source = DEFAULT_CATALOG_SOURCE) => {
|
|
43
|
+
const assetsByIconName = new Map();
|
|
44
|
+
for (const asset of catalog.assets) {
|
|
45
|
+
const normalizedName = normalizeLookupValue(asset.iconName);
|
|
46
|
+
const existingAssets = assetsByIconName.get(normalizedName) ?? [];
|
|
47
|
+
assetsByIconName.set(normalizedName, [...existingAssets, asset]);
|
|
48
|
+
}
|
|
49
|
+
const iconRecords = [...assetsByIconName.values()].map((assets) => toIconRecord(assets));
|
|
50
|
+
const iconLookup = new Map();
|
|
51
|
+
for (const icon of iconRecords) {
|
|
52
|
+
for (const key of getIconLookupKeys(icon)) {
|
|
53
|
+
iconLookup.set(key, icon);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const categories = [
|
|
57
|
+
...new Set(iconRecords.map((icon) => icon.category)),
|
|
58
|
+
].sort((left, right) => left.localeCompare(right));
|
|
59
|
+
const sizes = [
|
|
60
|
+
...new Set(iconRecords.flatMap((icon) => icon.sizes)),
|
|
61
|
+
].sort((left, right) => left.localeCompare(right));
|
|
62
|
+
return {
|
|
63
|
+
getMeta: () => ({
|
|
64
|
+
totalIconCount: iconRecords.length,
|
|
65
|
+
totalAssetCount: catalog.assets.length,
|
|
66
|
+
categories,
|
|
67
|
+
sizes,
|
|
68
|
+
source,
|
|
69
|
+
}),
|
|
70
|
+
listIcons: () => iconRecords.map((icon) => toIconSummary(icon)),
|
|
71
|
+
listIconAliases: () => iconRecords.map((icon) => ({
|
|
72
|
+
iconName: icon.iconName,
|
|
73
|
+
fileNames: icon.variants.map((variant) => variant.fileName),
|
|
74
|
+
})),
|
|
75
|
+
getIconByName: (nameOrFileName) => {
|
|
76
|
+
return iconLookup.get(normalizeLookupValue(nameOrFileName)) ?? null;
|
|
77
|
+
},
|
|
78
|
+
searchIcons: ({ query, limit, category, size, hasMapping }) => {
|
|
79
|
+
const normalizedCategory = category === undefined ? null : normalizeLookupValue(category);
|
|
80
|
+
const normalizedSize = size === undefined ? null : normalizeLookupValue(size);
|
|
81
|
+
const { totalMatches, hits } = searchRanked({
|
|
82
|
+
records: iconRecords,
|
|
83
|
+
query,
|
|
84
|
+
limit,
|
|
85
|
+
toRankable,
|
|
86
|
+
matches: (icon) => {
|
|
87
|
+
if (normalizedCategory !== null &&
|
|
88
|
+
normalizeLookupValue(icon.category) !== normalizedCategory) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (normalizedSize !== null && !icon.sizes.includes(normalizedSize)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return hasMapping === undefined || icon.hasMapping === hasMapping;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
totalMatches,
|
|
99
|
+
hits: hits.map((icon) => toIconSummary(icon)),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const iconCatalogLoader = createCatalogLoader({
|
|
105
|
+
specifier: "@hashicorp/flight-icons/catalog.json",
|
|
106
|
+
// icons reach most consumers through the components package they installed
|
|
107
|
+
anchors: ["project-root", "components", "default"],
|
|
108
|
+
create: (value, source) => createIconCatalogStore(parseIconCatalog(value), source),
|
|
109
|
+
});
|
|
110
|
+
export const loadIconCatalog = () => {
|
|
111
|
+
return iconCatalogLoader.load();
|
|
112
|
+
};
|
|
113
|
+
export const getOrLoadIconStore = () => {
|
|
114
|
+
return iconCatalogLoader.getOrLoad();
|
|
115
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { normalizeLookupValue } from "../../catalog/normalize.js";
|
|
6
|
+
export const getIconLookupKeys = (icon) => {
|
|
7
|
+
return [
|
|
8
|
+
normalizeLookupValue(icon.iconName),
|
|
9
|
+
...icon.variants.map((variant) => normalizeLookupValue(variant.fileName)),
|
|
10
|
+
];
|
|
11
|
+
};
|
|
12
|
+
const compareIconVariantsBySize = (left, right) => {
|
|
13
|
+
return left.size.localeCompare(right.size, undefined, { numeric: true });
|
|
14
|
+
};
|
|
15
|
+
export const toIconRecord = (assets) => {
|
|
16
|
+
const firstAsset = assets[0];
|
|
17
|
+
if (firstAsset === undefined) {
|
|
18
|
+
throw new Error("Cannot create icon record from an empty assets array.");
|
|
19
|
+
}
|
|
20
|
+
const variants = assets
|
|
21
|
+
.map((asset) => {
|
|
22
|
+
return {
|
|
23
|
+
id: asset.id,
|
|
24
|
+
fileName: asset.fileName,
|
|
25
|
+
size: asset.size,
|
|
26
|
+
width: asset.width,
|
|
27
|
+
height: asset.height,
|
|
28
|
+
...(asset.mapping === undefined ? {} : { mapping: asset.mapping }),
|
|
29
|
+
};
|
|
30
|
+
})
|
|
31
|
+
.sort(compareIconVariantsBySize);
|
|
32
|
+
const description = assets
|
|
33
|
+
.map((asset) => asset.description?.trim() ?? "")
|
|
34
|
+
.find((value) => value.length > 0) ?? "";
|
|
35
|
+
return {
|
|
36
|
+
iconName: firstAsset.iconName,
|
|
37
|
+
description,
|
|
38
|
+
category: firstAsset.category,
|
|
39
|
+
sizes: variants.map((variant) => variant.size),
|
|
40
|
+
hasMapping: variants.some((variant) => variant.mapping !== undefined),
|
|
41
|
+
variants,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
export const iconAssetSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
id: z.string().min(1),
|
|
9
|
+
fileName: z.string().min(1),
|
|
10
|
+
iconName: z.string().min(1),
|
|
11
|
+
description: z.string().optional(),
|
|
12
|
+
category: z.string().min(1),
|
|
13
|
+
size: z.string().min(1),
|
|
14
|
+
width: z.number(),
|
|
15
|
+
height: z.number(),
|
|
16
|
+
mapping: z.string().min(1).optional(),
|
|
17
|
+
})
|
|
18
|
+
.catchall(z.any());
|
|
19
|
+
const lastRunFigmaSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
id: z.string().min(1),
|
|
22
|
+
page: z.string().min(1),
|
|
23
|
+
excludeFrames: z.array(z.string()),
|
|
24
|
+
})
|
|
25
|
+
.catchall(z.any());
|
|
26
|
+
export const iconCatalogSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
lastRunFigma: lastRunFigmaSchema.optional(),
|
|
29
|
+
assets: z.array(iconAssetSchema),
|
|
30
|
+
})
|
|
31
|
+
.catchall(z.any());
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright IBM Corp. 2021, 2026
|
|
3
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
// relevance ordering for the substring catalogs: components, tokens and icons
|
|
6
|
+
import { normalizeLookupValue } from "../catalog/normalize.js";
|
|
7
|
+
/**
|
|
8
|
+
* A match is scored by where the query lands in the entry's identity fields — the names a
|
|
9
|
+
* caller would actually type. Without this an entry that merely contains the query can push
|
|
10
|
+
* the entry that *is* the query out of a truncated window: `form` matched 55 components and
|
|
11
|
+
* returned the 20 alphabetically-first, none of which was `Hds::Form`.
|
|
12
|
+
*/
|
|
13
|
+
export const RANK_EXACT = 500;
|
|
14
|
+
export const RANK_PREFIX = 400;
|
|
15
|
+
export const RANK_SEGMENT = 300;
|
|
16
|
+
export const RANK_IDENTITY = 200;
|
|
17
|
+
export const RANK_BLOB = 100;
|
|
18
|
+
export const RANK_NONE = 0;
|
|
19
|
+
// `hds::form::text-input`, `token-color-foreground-action` and `arrow-right-24` all delimit
|
|
20
|
+
// their parts with one of these
|
|
21
|
+
const SEGMENT_DELIMITER = /[\s:./_-]+/u;
|
|
22
|
+
/**
|
|
23
|
+
* The same name is delimited differently depending on where it is written: `hds::button` in a
|
|
24
|
+
* template, `hds/button` in an import, `border.radius.small` as a token key and `border-radius`
|
|
25
|
+
* as the CSS variable a caller actually types. Comparing on a delimiter-free form lets a query
|
|
26
|
+
* written in any of those styles reach the others, so `border-radius` still prefix-matches
|
|
27
|
+
* `border.radius.small` rather than losing to a token that merely contains the words.
|
|
28
|
+
*/
|
|
29
|
+
const canonicalize = (value) => {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/[{}]/gu, "")
|
|
32
|
+
.split(SEGMENT_DELIMITER)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join(" ");
|
|
35
|
+
};
|
|
36
|
+
export const scoreIdentity = (identity, query) => {
|
|
37
|
+
const canonicalIdentity = canonicalize(identity);
|
|
38
|
+
const canonicalQuery = canonicalize(query);
|
|
39
|
+
if (canonicalQuery.length === 0) {
|
|
40
|
+
return RANK_NONE;
|
|
41
|
+
}
|
|
42
|
+
if (identity === query) {
|
|
43
|
+
return RANK_EXACT;
|
|
44
|
+
}
|
|
45
|
+
if (canonicalIdentity === canonicalQuery) {
|
|
46
|
+
return RANK_EXACT;
|
|
47
|
+
}
|
|
48
|
+
if (identity.startsWith(query) ||
|
|
49
|
+
canonicalIdentity.startsWith(`${canonicalQuery} `)) {
|
|
50
|
+
return RANK_PREFIX;
|
|
51
|
+
}
|
|
52
|
+
// a whole run of segments, so `foreground` matches `color.foreground.action` but `oregon`
|
|
53
|
+
// does not, and a two-word query stays a phrase rather than two loose terms
|
|
54
|
+
if (` ${canonicalIdentity} `.includes(` ${canonicalQuery} `)) {
|
|
55
|
+
return RANK_SEGMENT;
|
|
56
|
+
}
|
|
57
|
+
if (identity.includes(query) || canonicalIdentity.includes(canonicalQuery)) {
|
|
58
|
+
return RANK_IDENTITY;
|
|
59
|
+
}
|
|
60
|
+
return RANK_NONE;
|
|
61
|
+
};
|
|
62
|
+
export const scoreEntry = (entry, query) => {
|
|
63
|
+
let score = RANK_NONE;
|
|
64
|
+
let identityLength = Number.MAX_SAFE_INTEGER;
|
|
65
|
+
for (const identity of entry.identities) {
|
|
66
|
+
const identityScore = scoreIdentity(identity, query);
|
|
67
|
+
if (identityScore === RANK_NONE) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (identityScore > score) {
|
|
71
|
+
score = identityScore;
|
|
72
|
+
identityLength = identity.length;
|
|
73
|
+
}
|
|
74
|
+
else if (identityScore === score && identity.length < identityLength) {
|
|
75
|
+
identityLength = identity.length;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (score !== RANK_NONE) {
|
|
79
|
+
return { score, identityLength };
|
|
80
|
+
}
|
|
81
|
+
// a keyword-only hit still counts: `warning` reaches `alert-triangle` through its description
|
|
82
|
+
return entry.blob.includes(query)
|
|
83
|
+
? { score: RANK_BLOB, identityLength }
|
|
84
|
+
: { score: RANK_NONE, identityLength };
|
|
85
|
+
};
|
|
86
|
+
// how far two keys agree from the left, which is what survives a typo in the tail
|
|
87
|
+
export const getCommonPrefixLength = (left, right) => {
|
|
88
|
+
const limit = Math.min(left.length, right.length);
|
|
89
|
+
let index = 0;
|
|
90
|
+
while (index < limit && left[index] === right[index]) {
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
return index;
|
|
94
|
+
};
|
|
95
|
+
export const searchRanked = ({ records, query, limit, toRankable, matches, }) => {
|
|
96
|
+
const normalizedQuery = normalizeLookupValue(query);
|
|
97
|
+
const filtered = records.filter((record) => matches === undefined || matches(record));
|
|
98
|
+
// an all-whitespace query is a browse, not a search, so the catalog keeps its own order
|
|
99
|
+
if (normalizedQuery.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
totalMatches: filtered.length,
|
|
102
|
+
hits: filtered.slice(0, limit),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (canonicalize(normalizedQuery).length === 0) {
|
|
106
|
+
return { totalMatches: 0, hits: [] };
|
|
107
|
+
}
|
|
108
|
+
const scored = [];
|
|
109
|
+
for (const [index, record] of filtered.entries()) {
|
|
110
|
+
const { score, identityLength } = scoreEntry(toRankable(record), normalizedQuery);
|
|
111
|
+
if (score === RANK_NONE) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
scored.push({ record, score, identityLength, index });
|
|
115
|
+
}
|
|
116
|
+
scored.sort((left, right) => {
|
|
117
|
+
if (right.score !== left.score) {
|
|
118
|
+
return right.score - left.score;
|
|
119
|
+
}
|
|
120
|
+
if (left.identityLength !== right.identityLength) {
|
|
121
|
+
return left.identityLength - right.identityLength;
|
|
122
|
+
}
|
|
123
|
+
return left.index - right.index;
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
totalMatches: scored.length,
|
|
127
|
+
hits: scored.slice(0, limit).map((entry) => entry.record),
|
|
128
|
+
};
|
|
129
|
+
};
|