diff --git a/src/__tests__/__snapshots__/docs.embedded.test.ts.snap b/src/__tests__/__snapshots__/docs.embedded.test.ts.snap index 4f9155e..4f21ec8 100644 --- a/src/__tests__/__snapshots__/docs.embedded.test.ts.snap +++ b/src/__tests__/__snapshots__/docs.embedded.test.ts.snap @@ -38,7 +38,7 @@ exports[`EMBEDDED_DOCS should export the expected embedded docs structure: docs }, { "category": "reference", - "description": "PatternFly organization on GitHub (Core & React).", + "description": "PatternFly organization on GitHub.", "displayName": "PatternFly GitHub", "path": "https://github.com/patternfly", "pathSlug": "patternfly-github", diff --git a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap index 5b88bd0..3cb0947 100644 --- a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap +++ b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap @@ -15,8 +15,9 @@ exports[`getPatternFlyMcpResources should return multiple organized facets: prop "resources", "docsIndex", "componentsIndex", - "keywordsIndex", "isFallbackDocumentation", + "keywordsIndex", + "keywordsMap", "pathIndex", "byPath", "byUri", @@ -27,10 +28,9 @@ exports[`getPatternFlyMcpResources should return multiple organized facets: prop exports[`getPatternFlyReactComponentNames should return multiple organized facets: properties 1`] = ` [ - "byVersion", "componentNamesIndex", - "componentNamesWithSchemasIndex", - "componentNamesWithSchemasMap", + "componentNamesIndexMap", + "byVersion", ] `; diff --git a/src/__tests__/__snapshots__/patternFly.helpers.test.ts.snap b/src/__tests__/__snapshots__/patternFly.helpers.test.ts.snap index 34957fd..86adb04 100644 --- a/src/__tests__/__snapshots__/patternFly.helpers.test.ts.snap +++ b/src/__tests__/__snapshots__/patternFly.helpers.test.ts.snap @@ -1,89 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, current 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, detected 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, empty 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, exact semver 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, latest 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, null 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, semver 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, tag 1`] = ` -[ - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, unavailable exact semver 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, unavailable semver 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, unavailable tag 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - -exports[`filterEnumeratedPatternFlyVersions should attempt to refine a PatternFly versions based on available enumerations, undefined 1`] = ` -[ - "current", - "latest", - "v6", -] -`; - exports[`getPatternFlyVersionContext should temporarily return option.defaults and latest versions with specific properties: keys 1`] = ` [ "availableSemVer", diff --git a/src/__tests__/__snapshots__/patternFly.search.test.ts.snap b/src/__tests__/__snapshots__/patternFly.search.test.ts.snap index bf89a3e..abc8567 100644 --- a/src/__tests__/__snapshots__/patternFly.search.test.ts.snap +++ b/src/__tests__/__snapshots__/patternFly.search.test.ts.snap @@ -5,6 +5,9 @@ exports[`searchPatternFly should attempt to return an array of all available res "isSearchWildCardAll", "firstExactMatch", "exactMatches", + "remainingMatches", + "totalResults", + "totalPotentialMatches", ] `; @@ -13,6 +16,9 @@ exports[`searchPatternFly should attempt to return an array of all available res "isSearchWildCardAll", "firstExactMatch", "exactMatches", + "remainingMatches", + "totalResults", + "totalPotentialMatches", ] `; @@ -21,5 +27,8 @@ exports[`searchPatternFly should attempt to return an array of all available res "isSearchWildCardAll", "firstExactMatch", "exactMatches", + "remainingMatches", + "totalResults", + "totalPotentialMatches", ] `; diff --git a/src/__tests__/__snapshots__/server.search.test.ts.snap b/src/__tests__/__snapshots__/server.search.test.ts.snap index 65a7e5f..c241988 100644 --- a/src/__tests__/__snapshots__/server.search.test.ts.snap +++ b/src/__tests__/__snapshots__/server.search.test.ts.snap @@ -91,344 +91,526 @@ exports[`findClosest should attempt to find a closest match, undefined items 1`] } `; +exports[`findClosest should handle numbers in addition to strings, float against float query 1`] = `123.44`; + +exports[`findClosest should handle numbers in addition to strings, float number query 1`] = `123`; + +exports[`findClosest should handle numbers in addition to strings, number query 1`] = `123`; + +exports[`findClosest should handle numbers in addition to strings, number query with float 1`] = `123.45`; + +exports[`findClosest should handle numbers in addition to strings, string query 1`] = `"Button"`; + exports[`fuzzySearch should fuzzy match, contains match multiple 1`] = ` -[ - { - "distance": 1, - "item": "AlertGroup", - "matchType": "suffix", - }, - { - "distance": 1, - "item": "BadgeGroup", - "matchType": "suffix", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "suffix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "AlertGroup", + "matchType": "suffix", + }, + { + "distance": 1, + "item": "BadgeGroup", + "matchType": "suffix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "suffix", + }, + ], + "totalResults": 3, + "totalResultsReturned": 3, +} `; exports[`fuzzySearch should fuzzy match, deduplicate by normalized value 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; exports[`fuzzySearch should fuzzy match, duplicate items 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; -exports[`fuzzySearch should fuzzy match, empty items 1`] = `[]`; +exports[`fuzzySearch should fuzzy match, empty items 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; -exports[`fuzzySearch should fuzzy match, empty query 1`] = `[]`; +exports[`fuzzySearch should fuzzy match, empty query 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; exports[`fuzzySearch should fuzzy match, empty query against maxDistance 1`] = ` -[ - { - "distance": 1, - "item": "A", - "matchType": "fuzzy", - }, - { - "distance": 2, - "item": "AB", - "matchType": "fuzzy", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "A", + "matchType": "fuzzy", + }, + { + "distance": 2, + "item": "AB", + "matchType": "fuzzy", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, empty query extended distance 1`] = ` -[ - { - "distance": 4, - "item": "Card", - "matchType": "fuzzy", - }, - { - "distance": 5, - "item": "Alert", - "matchType": "fuzzy", - }, - { - "distance": 5, - "item": "Badge", - "matchType": "fuzzy", - }, - { - "distance": 6, - "item": "Button", - "matchType": "fuzzy", - }, - { - "distance": 10, - "item": "AlertGroup", - "matchType": "fuzzy", - }, - { - "distance": 10, - "item": "BadgeGroup", - "matchType": "fuzzy", - }, - { - "distance": 10, - "item": "CardHeader", - "matchType": "fuzzy", - }, - { - "distance": 11, - "item": "ButtonGroup", - "matchType": "fuzzy", - }, -] +{ + "results": [ + { + "distance": 4, + "item": "Card", + "matchType": "fuzzy", + }, + { + "distance": 5, + "item": "Alert", + "matchType": "fuzzy", + }, + { + "distance": 5, + "item": "Badge", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Button", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "AlertGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "fuzzy", + }, + { + "distance": 11, + "item": "ButtonGroup", + "matchType": "fuzzy", + }, + ], + "totalResults": 8, + "totalResultsReturned": 8, +} `; exports[`fuzzySearch should fuzzy match, exact match 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, exact match case-insensitive 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, fuzzy match within distance 1`] = ` -[ - { - "distance": 5, - "item": "Badge", - "matchType": "fuzzy", - }, - { - "distance": 6, - "item": "Alert", - "matchType": "fuzzy", - }, - { - "distance": 6, - "item": "Card", - "matchType": "fuzzy", - }, - { - "distance": 8, - "item": "AlertGroup", - "matchType": "fuzzy", - }, - { - "distance": 8, - "item": "BadgeGroup", - "matchType": "fuzzy", - }, - { - "distance": 10, - "item": "CardHeader", - "matchType": "fuzzy", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, length-delta precheck for maxDistance 1`] = `[]`; +{ + "results": [ + { + "distance": 5, + "item": "Badge", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Alert", + "matchType": "fuzzy", + }, + { + "distance": 6, + "item": "Card", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "AlertGroup", + "matchType": "fuzzy", + }, + { + "distance": 8, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + { + "distance": 10, + "item": "CardHeader", + "matchType": "fuzzy", + }, + ], + "totalResults": 6, + "totalResultsReturned": 6, +} +`; + +exports[`fuzzySearch should fuzzy match, length-delta precheck for maxDistance 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; exports[`fuzzySearch should fuzzy match, match within max results 1`] = ` -[ - { - "distance": 1, - "item": "Alert", - "matchType": "prefix", - }, - { - "distance": 1, - "item": "AlertGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "Alert", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "AlertGroup", + "matchType": "prefix", + }, + ], + "totalResults": 7, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, match within restricted distance 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, matches are alphabetized 1`] = ` -[ - { - "distance": 1, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, matches are normalized 1`] = ` -[ - { - "distance": 0, - "item": "resume", - "matchType": "exact", - }, - { - "distance": 0, - "item": "RESUME", - "matchType": "exact", - }, - { - "distance": 0, - "item": "Résumé", - "matchType": "exact", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "resume", + "matchType": "exact", + }, + { + "distance": 0, + "item": "RESUME", + "matchType": "exact", + }, + { + "distance": 0, + "item": "Résumé", + "matchType": "exact", + }, + ], + "totalResults": 3, + "totalResultsReturned": 3, +} `; exports[`fuzzySearch should fuzzy match, mixed types by maxDistance 1`] = ` -[ - { - "distance": 1, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, multiple words 1`] = ` -[ - { - "distance": 1, - "item": "BadgeGroup", - "matchType": "fuzzy", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "BadgeGroup", + "matchType": "fuzzy", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; exports[`fuzzySearch should fuzzy match, multiple words maxDistance 1`] = ` -[ - { - "distance": 2, - "item": "Lorem Ipsum Dolor Sit", - "matchType": "contains", - }, -] +{ + "results": [ + { + "distance": 2, + "item": "Lorem Ipsum Dolor Sit", + "matchType": "contains", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; -exports[`fuzzySearch should fuzzy match, negative maxDistance 1`] = `[]`; +exports[`fuzzySearch should fuzzy match, negative maxDistance 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; -exports[`fuzzySearch should fuzzy match, null items 1`] = `[]`; +exports[`fuzzySearch should fuzzy match, null items 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; exports[`fuzzySearch should fuzzy match, prefix match 1`] = ` -[ - { - "distance": 1, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, prefix match multiple 1`] = ` -[ - { - "distance": 1, - "item": "Button", - "matchType": "prefix", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "Button", + "matchType": "prefix", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} `; exports[`fuzzySearch should fuzzy match, single item 1`] = ` -[ - { - "distance": 0, - "item": "BUTTON", - "matchType": "exact", - }, -] +{ + "results": [ + { + "distance": 0, + "item": "BUTTON", + "matchType": "exact", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; exports[`fuzzySearch should fuzzy match, suffix match 1`] = ` -[ - { - "distance": 1, - "item": "CardHeader", - "matchType": "suffix", - }, -] +{ + "results": [ + { + "distance": 1, + "item": "CardHeader", + "matchType": "suffix", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} `; exports[`fuzzySearch should fuzzy match, trimmed query 1`] = ` -[ - { - "distance": 0, - "item": "Button", - "matchType": "exact", - }, - { - "distance": 1, - "item": "ButtonGroup", - "matchType": "prefix", - }, -] -`; - -exports[`fuzzySearch should fuzzy match, undefined items 1`] = `[]`; +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + { + "distance": 1, + "item": "ButtonGroup", + "matchType": "prefix", + }, + ], + "totalResults": 2, + "totalResultsReturned": 2, +} +`; + +exports[`fuzzySearch should fuzzy match, undefined items 1`] = ` +{ + "results": [], + "totalResults": 0, + "totalResultsReturned": 0, +} +`; + +exports[`fuzzySearch should handle numbers in addition to strings, exact number query 1`] = ` +{ + "results": [ + { + "distance": 0, + "item": "123", + "matchType": "exact", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} +`; + +exports[`fuzzySearch should handle numbers in addition to strings, partial float number query 1`] = ` +{ + "results": [ + { + "distance": 2, + "item": "123", + "matchType": "partial", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} +`; + +exports[`fuzzySearch should handle numbers in addition to strings, prefix number query with float 1`] = ` +{ + "results": [ + { + "distance": 1, + "item": "123.45", + "matchType": "prefix", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} +`; + +exports[`fuzzySearch should handle numbers in addition to strings, string query 1`] = ` +{ + "results": [ + { + "distance": 0, + "item": "Button", + "matchType": "exact", + }, + ], + "totalResults": 1, + "totalResultsReturned": 1, +} +`; diff --git a/src/__tests__/docs.embedded.test.ts b/src/__tests__/docs.embedded.test.ts index 705ff94..769192e 100644 --- a/src/__tests__/docs.embedded.test.ts +++ b/src/__tests__/docs.embedded.test.ts @@ -16,4 +16,23 @@ describe('EMBEDDED_DOCS', () => { expect(allDocs.length).toBeGreaterThanOrEqual(5); }); + + it('should have metadata reflective of its JSON content', () => { + const { docs, meta } = EMBEDDED_DOCS; + + expect(meta.totalEntries).toBeDefined(); + expect(Object.entries(docs).length).toBe(meta.totalEntries); + + expect(meta.totalDocs).toBeDefined(); + + let totalDocs = 0; + + Object.values(docs).forEach(entries => { + if (Array.isArray(entries)) { + totalDocs += entries.length; + } + }); + + expect(totalDocs).toBe(meta.totalDocs); + }); }); diff --git a/src/__tests__/docs.filterWords.test.ts b/src/__tests__/docs.filterWords.test.ts new file mode 100644 index 0000000..6a4636e --- /dev/null +++ b/src/__tests__/docs.filterWords.test.ts @@ -0,0 +1,15 @@ +import { INDEX_BLOCKLIST_WORDS, INDEX_NOISE_WORDS } from '../docs.filterWords'; + +describe('INDEX_BLOCKLIST_WORDS', () => { + it('should be defined and contain words', () => { + expect(INDEX_BLOCKLIST_WORDS.length).toBeGreaterThanOrEqual(0); + expect(INDEX_BLOCKLIST_WORDS).toBeDefined(); + }); +}); + +describe('INDEX_NOISE_WORDS', () => { + it('should be defined and contain words', () => { + expect(INDEX_NOISE_WORDS.length).toBeGreaterThanOrEqual(0); + expect(INDEX_NOISE_WORDS).toBeDefined(); + }); +}); diff --git a/src/__tests__/docs.json.test.ts b/src/__tests__/docs.json.test.ts new file mode 100644 index 0000000..1526563 --- /dev/null +++ b/src/__tests__/docs.json.test.ts @@ -0,0 +1,20 @@ +import docs from '../docs.json'; + +describe('docs.json', () => { + it('should have metadata reflective of its JSON content', () => { + expect(docs.meta.totalEntries).toBeDefined(); + expect(Object.entries(docs.docs).length).toBe(docs.meta.totalEntries); + + expect(docs.meta.totalDocs).toBeDefined(); + + let totalDocs = 0; + + Object.values(docs.docs).forEach(entries => { + if (Array.isArray(entries)) { + totalDocs += entries.length; + } + }); + + expect(totalDocs).toBe(docs.meta.totalDocs); + }); +}); diff --git a/src/__tests__/patternFly.getResources.test.ts b/src/__tests__/patternFly.getResources.test.ts index 2f6bd98..06086b2 100644 --- a/src/__tests__/patternFly.getResources.test.ts +++ b/src/__tests__/patternFly.getResources.test.ts @@ -1,7 +1,7 @@ import { setCategoryDisplayLabel, getPatternFlyComponentSchema, - getPatternFlyReactComponentNames, + getPatternFlyComponentNames, getPatternFlyMcpResources } from '../patternFly.getResources'; @@ -86,13 +86,13 @@ describe('getPatternFlyComponentSchema', () => { describe('getPatternFlyReactComponentNames', () => { it('should return multiple organized facets', async () => { - const result = await getPatternFlyReactComponentNames(); + const result = await getPatternFlyComponentNames(); expect(Object.keys(result)).toMatchSnapshot('properties'); }); it('should have a memoized property', () => { - expect(getPatternFlyReactComponentNames).toHaveProperty('memo'); + expect(getPatternFlyComponentNames).toHaveProperty('memo'); }); }); diff --git a/src/__tests__/patternFly.helpers.test.ts b/src/__tests__/patternFly.helpers.test.ts index 39b9e6b..2ddc316 100644 --- a/src/__tests__/patternFly.helpers.test.ts +++ b/src/__tests__/patternFly.helpers.test.ts @@ -2,7 +2,6 @@ import { findClosestPatternFlyVersion, getPatternFlyVersionContext, normalizeEnumeratedPatternFlyVersion, - filterEnumeratedPatternFlyVersions, disabled_findClosestPatternFlyVersion } from '../patternFly.helpers'; import { readLocalFileFunction } from '../server.getResources'; @@ -115,63 +114,6 @@ describe('normalizeEnumeratedPatternFlyVersion', () => { }); }); -describe('filterEnumeratedPatternFlyVersions', () => { - it.each([ - { - description: 'exact semver', - version: '6.0.0' - }, - { - description: 'semver', - version: '6.4.10' - }, - { - description: 'tag', - version: 'v6' - }, - { - description: 'current', - version: 'current' - }, - { - description: 'latest', - version: 'latest' - }, - { - description: 'detected', - version: 'detected' - }, - { - description: 'unavailable exact semver', - version: '5.0.0' - }, - { - description: 'unavailable semver', - version: '5.2.10' - }, - { - description: 'unavailable tag', - version: 'v5' - }, - { - description: 'undefined', - version: undefined - }, - { - description: 'null', - version: null - }, - { - description: 'empty', - version: '' - } - ])('should attempt to refine a PatternFly versions based on available enumerations, $description', async ({ version }) => { - const result = await filterEnumeratedPatternFlyVersions(version as any); - - expect(result).toMatchSnapshot(); - }); -}); - describe('disabled_findClosestPatternFlyVersion', () => { it.each([ { diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index c014989..b3a5817 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -1,4 +1,44 @@ -import { searchPatternFly } from '../patternFly.search'; +import { filterPatternFly, searchPatternFly } from '../patternFly.search'; + +describe('filterPatternFly', () => { + it.each([ + { + description: 'all filter', + filters: undefined + }, + { + description: 'all filter empty object', + filters: {} + }, + { + description: 'all filter empty object', + filters: { version: 'v5' } + }, + { + description: 'section, components', + filters: { section: 'components' } + }, + { + description: 'category, accessibility', + filters: { category: 'accessibility' } + } + ])('should attempt to return filtered results, $description', async ({ filters }) => { + const result = await filterPatternFly(filters as any); + + expect(result.byEntry.length).toBeGreaterThanOrEqual(0); + expect(Array.from(result.byResource).length).toBeGreaterThanOrEqual(0); + }); + + it('should attempt to filter number results', async () => { + const result = await filterPatternFly( + { section: 1 } as any, + new Map([['loremIpsum', { entries: [{ section: 1 }, { section: 'dolor' }] }]]) as any + ); + + expect(result.byEntry).toEqual(expect.arrayContaining([{ section: 1 }])); + expect(Array.from(result.byResource).length).toBeGreaterThanOrEqual(0); + }); +}); describe('searchPatternFly', () => { it.each([ @@ -15,7 +55,7 @@ describe('searchPatternFly', () => { search: '' } ])('should attempt to return an array of all available results, $description', async ({ search }) => { - const { searchResults, ...rest } = await searchPatternFly(search, { allowWildCardAll: true }); + const { searchResults, ...rest } = await searchPatternFly(search, undefined, { allowWildCardAll: true }); expect(searchResults.length).toBeGreaterThan(0); expect(Object.keys(rest)).toMatchSnapshot('keys'); @@ -24,32 +64,54 @@ describe('searchPatternFly', () => { it.each([ { description: 'exact match', - search: 'button', + search: 'react', matchType: 'exact' }, { description: 'partial prefix match', - search: 'bu', + search: 're', matchType: 'prefix' }, { description: 'partial suffix match', - search: 'tton', + search: 'act', matchType: 'suffix' }, { description: 'partial contains match', - search: 'utt', + search: 'eac', matchType: 'contains' } ])('should attempt to match components and keywords, $description', async ({ search, matchType }) => { const { searchResults } = await searchPatternFly(search); - expect(searchResults.filter(({ matchType: returnMatchType }) => returnMatchType === matchType)).toEqual([ - expect.objectContaining({ - item: expect.stringContaining(search), - query: expect.stringMatching(search) - }) - ]); + expect(searchResults.find(({ matchType: returnMatchType }) => returnMatchType === matchType)).toEqual(expect.objectContaining({ + query: expect.stringMatching(search) + })); + }); + + it.each([ + { + description: 'version', + search: 'about modal', + filters: { version: 'v5' } + }, + { + description: 'section', + search: 'popover', + filters: { section: 'components' } + }, + { + description: 'category', + search: '*', + filters: { category: 'grammar' }, + options: { allowWildCardAll: true } + } + ])('should allow filtering, $description', async ({ search, filters, options }) => { + const { searchResults, totalResults, totalPotentialMatches } = await searchPatternFly(search, filters, options || {}); + + expect(searchResults.length).toBeGreaterThanOrEqual(0); + expect(totalResults).toBeGreaterThanOrEqual(searchResults.length); + expect(totalPotentialMatches).toBeGreaterThanOrEqual(totalResults); }); }); diff --git a/src/__tests__/server.search.test.ts b/src/__tests__/server.search.test.ts index dbe9b4f..3fe9bc8 100644 --- a/src/__tests__/server.search.test.ts +++ b/src/__tests__/server.search.test.ts @@ -82,7 +82,7 @@ describe('findClosest', () => { ])('should attempt to find a closest match, $description', ({ query, items }) => { expect({ query, - match: findClosest(query, items as string[]) + match: findClosest(query, items as any) }).toMatchSnapshot(); }); @@ -95,6 +95,36 @@ describe('findClosest', () => { findClosest('button', ['Button', 'Badge'], { normalizeFn: throwingNormalizeFn }); }).toThrow('Normalization failed'); }); + + it.each([ + { + description: 'string query', + query: 'button', + items: ['Button', 123, 'Badge'] + }, + { + description: 'number query', + query: 123, + items: ['Button', 123, 'Badge'] + }, + { + description: 'number query with float', + query: 123, + items: ['Button', 123.45, 'Badge'] + }, + { + description: 'float number query', + query: 123.45, + items: ['Button', 123, 'Badge'] + }, + { + description: 'float against float query', + query: 123.45, + items: ['Button', 123, undefined, 123.44, 'Badge', null] + } + ])('should handle numbers in addition to strings, $description', ({ query, items }) => { + expect(findClosest(query, items as any)).toMatchSnapshot(); + }); }); describe('fuzzySearch', () => { @@ -191,6 +221,7 @@ describe('fuzzySearch', () => { query: '', items: components, options: { + allowEmptyQuery: true, maxDistance: 20, isFuzzyMatch: true } @@ -243,6 +274,7 @@ describe('fuzzySearch', () => { query: '', items: ['A', 'AB', 'ABCDE', 'ABCDEFG'], options: { + allowEmptyQuery: true, maxDistance: 3, isFuzzyMatch: true } @@ -310,7 +342,7 @@ describe('fuzzySearch', () => { } } ])('should fuzzy match, $description', ({ query, items, options }) => { - expect(fuzzySearch(query, items as string[], options)).toMatchSnapshot(); + expect(fuzzySearch(query, items as any, options)).toMatchSnapshot(); }); it('should handle normalizeFn errors in fuzzySearch', () => { @@ -322,4 +354,29 @@ describe('fuzzySearch', () => { fuzzySearch('button', ['Button', 'Badge'], { normalizeFn: throwingNormalizeFn }); }).toThrow('Normalization failed'); }); + + it.each([ + { + description: 'string query', + query: 'button', + items: ['Button', 123, 'Badge'] + }, + { + description: 'exact number query', + query: 123, + items: ['Button', 123, 'Badge'] + }, + { + description: 'prefix number query with float', + query: 123, + items: ['Button', 123.45, 'Badge'] + }, + { + description: 'partial float number query', + query: 123.45, + items: ['Button', 123, 'Badge'] + } + ])('should handle numbers in addition to strings, $description', ({ query, items }) => { + expect(fuzzySearch(query, items, { deduplicateByNormalized: true })).toMatchSnapshot(); + }); }); diff --git a/src/docs.embedded.ts b/src/docs.embedded.ts index 314e43d..6c09d30 100644 --- a/src/docs.embedded.ts +++ b/src/docs.embedded.ts @@ -63,7 +63,7 @@ const EMBEDDED_DOCS: PatternFlyMcpDocsCatalog = { }, { displayName: 'PatternFly GitHub', - description: 'PatternFly organization on GitHub (Core & React).', + description: 'PatternFly organization on GitHub.', pathSlug: 'patternfly-github', section: 'github', category: 'reference', diff --git a/src/docs.filterWords.ts b/src/docs.filterWords.ts new file mode 100644 index 0000000..6adc111 --- /dev/null +++ b/src/docs.filterWords.ts @@ -0,0 +1,115 @@ +/** + * Index keyword filtering for high-volume matches. + * + * @note It's tempting to remove category and section names from this list, don't. Instead, the search + * should be leveraging filters which allow for "section" and "category" specifically. + */ +const INDEX_BLOCKLIST_WORDS = ['patternfly', 'component', 'components', 'documentation', 'example', 'examples']; + +/** + * Noise words that are common and do not add significant value to search results. + */ +const INDEX_NOISE_WORDS = [ + 'about', + 'actually', + 'allows', + 'almost', + 'also', + 'although', + 'always', + 'and', + 'another', + 'appropriate', + 'available', + 'based', + 'because', + 'been', + 'before', + 'being', + 'between', + 'certain', + 'could', + 'does', + 'during', + 'either', + 'enough', + 'ever', + 'find', + 'first', + 'give', + 'goes', + 'gone', + 'have', + 'however', + 'just', + 'keep', + 'many', + 'maybe', + 'more', + 'most', + 'must', + 'neither', + 'never', + 'next', + 'nothing', + 'often', + 'other', + 'otherwise', + 'perhaps', + 'please', + 'possible', + 'provide', + 'rather', + 'really', + 'said', + 'same', + 'says', + 'seem', + 'self', + 'several', + 'should', + 'show', + 'since', + 'some', + 'still', + 'such', + 'sure', + 'take', + 'the', + 'than', + 'that', + 'their', + 'them', + 'then', + 'there', + 'they', + 'thing', + 'this', + 'those', + 'though', + 'thus', + 'together', + 'towards', + 'under', + 'until', + 'upon', + 'used', + 'using', + 'various', + 'very', + 'well', + 'were', + 'what', + 'when', + 'where', + 'whether', + 'which', + 'while', + 'whose', + 'will', + 'with', + 'would', + 'you' +]; + +export { INDEX_BLOCKLIST_WORDS, INDEX_NOISE_WORDS }; diff --git a/src/docs.json b/src/docs.json index b98c661..3df5335 100644 --- a/src/docs.json +++ b/src/docs.json @@ -2,7 +2,7 @@ "version": "1", "generated": "2026-02-18T00:00:00.000Z", "meta": { - "totalEntries": 126, + "totalEntries": 115, "totalDocs": 267, "source": "patternfly-mcp-internal" }, @@ -2129,6 +2129,16 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-react/476782a298df81cb78de7f3cd804f3ff8f33993c/packages/react-charts/src/victory/components/ChartTooltip/examples/ChartTooltip.md", "version": "v6" + }, + { + "displayName": "Tooltip content", + "description": "Guidance on writing tooltips, including specific rules for certain icons.", + "pathSlug": "tooltip", + "section": "content-design", + "category": "writing-guides", + "source": "github", + "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/tooltips.md", + "version": "v6" } ], "TreeView": [ @@ -2737,7 +2747,43 @@ "version": "v6" } ], - "Content design overview": [ + "Product tours": [ + { + "displayName": "Product tours", + "description": "Best practices and tone advice for writing content for product tours and onboarding flows.", + "pathSlug": "product-tours", + "section": "content-design", + "category": "writing-guides", + "source": "github", + "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/product-tours.md", + "version": "v6" + } + ], + "CLI guidelines": [ + { + "displayName": "CLI guidelines", + "description": "Detailed guidance for designing and writing content for text-based command-line interfaces (CLIs).", + "pathSlug": "cli-guidelines", + "section": "content-design", + "category": "writing-guides", + "source": "github", + "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/cli-guidelines.md", + "version": "v6" + } + ], + "Error messages": [ + { + "displayName": "Error messages", + "description": "Best practices for writing effective error messages that are clear, actionable, and empathetic.", + "pathSlug": "error-messages", + "section": "content-design", + "category": "writing-guides", + "source": "github", + "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/error-messages.md", + "version": "v6" + } + ], + "Writing": [ { "displayName": "Content design overview", "description": "Overview of content design and a map of available writing resources for PatternFly.", @@ -2747,9 +2793,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/content-design.md", "version": "v6" - } - ], - "Brand voice and tone": [ + }, { "displayName": "Brand voice and tone", "description": "Defines the unique voice and tone standards that ensure consistency and humanity across PatternFly products.", @@ -2759,21 +2803,17 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/brand-voice-and-tone.md", "version": "v6" - } - ], - "Best practices": [ + }, { "displayName": "Best practices", "description": "Foundational advice for integrating words into your design process, ensuring intuitive and human-centered experiences.", - "pathSlug": "best-practices", + "pathSlug": "writing-best-practices", "section": "content-design", "category": "writing-guides", "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/best-practices.md", "version": "v6" - } - ], - "Accessibility and localization": [ + }, { "displayName": "Accessibility and localization", "description": "Guidance on writing for inclusion, supporting screen readers, and adapting content for global audiences.", @@ -2783,9 +2823,19 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/accessibility-and-localization.md", "version": "v6" + }, + { + "displayName": "PatternFly design guidelines content", + "description": "Guidance on structuring and writing content specifically for PatternFly's design guidelines.", + "pathSlug": "patternfly-writing-in-design", + "section": "content-design", + "category": "writing-guides", + "source": "github", + "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/patternfly-design-guidelines.md", + "version": "v6" } ], - "Capitalization": [ + "Grammar": [ { "displayName": "Capitalization", "description": "Rules detailing which casing style to use for different UI scenarios.", @@ -2795,9 +2845,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/capitalization.md", "version": "v6" - } - ], - "Numerics": [ + }, { "displayName": "Numerics", "description": "Rules for displaying and formatting dates, times, currency, and numerical values.", @@ -2807,9 +2855,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/numerics.md", "version": "v6" - } - ], - "Punctuation": [ + }, { "displayName": "Punctuation", "description": "Rules for using punctuation properly within UI components and long-form content.", @@ -2819,9 +2865,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/punctuation.md", "version": "v6" - } - ], - "Sentence structure": [ + }, { "displayName": "Sentence structure", "description": "Guidance on point of view and sentence voice to ensure clarity and user-focused language.", @@ -2831,9 +2875,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/sentence-structure.md", "version": "v6" - } - ], - "Terminology": [ + }, { "displayName": "Terminology", "description": "Preferred terms across different UI scenarios, including words and phrases to avoid.", @@ -2843,9 +2885,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/terminology.md", "version": "v6" - } - ], - "Truncation": [ + }, { "displayName": "Truncation", "description": "Guidance on replacing overflow content with ellipses to manage text when display space is limited.", @@ -2855,9 +2895,7 @@ "source": "github", "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/truncation.md", "version": "v6" - } - ], - "Units and symbols": [ + }, { "displayName": "Units and symbols", "description": "Rules for displaying units of measurement and shorthand symbols.", @@ -2868,66 +2906,6 @@ "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/grammar/units-and-symbols.md", "version": "v6" } - ], - "CLI guidelines": [ - { - "displayName": "CLI guidelines", - "description": "Detailed guidance for designing and writing content for text-based command-line interfaces (CLIs).", - "pathSlug": "cli-guidelines", - "section": "content-design", - "category": "writing-guides", - "source": "github", - "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/cli-guidelines.md", - "version": "v6" - } - ], - "Error messages": [ - { - "displayName": "Error messages", - "description": "Best practices for writing effective error messages that are clear, actionable, and empathetic.", - "pathSlug": "error-messages", - "section": "content-design", - "category": "writing-guides", - "source": "github", - "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/error-messages.md", - "version": "v6" - } - ], - "PatternFly design guidelines content": [ - { - "displayName": "PatternFly design guidelines content", - "description": "Guidance on structuring and writing content specifically for PatternFly's design guidelines.", - "pathSlug": "patternfly-design-guidelines", - "section": "content-design", - "category": "writing-guides", - "source": "github", - "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/patternfly-design-guidelines.md", - "version": "v6" - } - ], - "Product tours": [ - { - "displayName": "Product tours", - "description": "Best practices and tone advice for writing content for product tours and onboarding flows.", - "pathSlug": "product-tours", - "section": "content-design", - "category": "writing-guides", - "source": "github", - "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/product-tours.md", - "version": "v6" - } - ], - "Tooltips content": [ - { - "displayName": "Tooltips content", - "description": "Guidance on writing tooltips, including specific rules for certain icons.", - "pathSlug": "tooltips", - "section": "content-design", - "category": "writing-guides", - "source": "github", - "path": "https://raw.githubusercontent.com/patternfly/patternfly-org/3f5653a2742910515279c5c32cde9f2f6a87ca79/packages/documentation-site/patternfly-docs/content/content-design/writing-guides/tooltips.md", - "version": "v6" - } ] } } diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index 3e042b8..d0c985f 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -15,14 +15,33 @@ import { type PatternFlyMcpDocsCatalogEntry, type PatternFlyMcpDocsCatalogDoc } from './docs.embedded'; +import { INDEX_BLOCKLIST_WORDS, INDEX_NOISE_WORDS } from './docs.filterWords'; /** * Derive the component schema type from @patternfly/patternfly-component-schemas */ type PatternFlyComponentSchema = Awaited>; +type PatternFlyMcpComponentNamesByVersion = { + [name: string]: { + isSchemasAvailable: boolean; + displayName: string; + } +}; + +interface PatternFlyMcpComponentNames { + componentNamesIndex: string[]; + componentNamesIndexMap: Map; + byVersion: Map; +} + /** * PatternFly JSON extended documentation metadata + * + * @property name - The name of component entry. + * @property displayCategory - The display category of component entry. + * @property uri - The parent resource URI of component entry. + * @property uriSchemas - The parent resource URI of component schemas. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** */ type PatternFlyMcpDocsMeta = { name: string; @@ -60,36 +79,30 @@ type PatternFlyMcpResourcesByVersion = { [version: string]: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; }; +/** + * PatternFly resource keywords by resource name then by version. + */ +type PatternFlyMcpKeywordsMap = Map>; + /** * PatternFly resource metadata. * * @note This might need to be called resource metadata. `docs.json` doesn't just contain component metadata. * * @property name - The name of component entry. - * @property urls - All entry URLs for component documentation. - * @property urlsNoGuidance - All entry URLs for component documentation without AI guidance. - * @property urlsGuidance - All entry URLs for component documentation with AI guidance. - * @property entriesGuidance - All entry PatternFly documentation entries with AI guidance. - * @property entriesNoGuidance - All entry PatternFly documentation entries without AI guidance. - * @property versions - Entry segmented by versions. + * @property isSchemasAvailable - Whether schemas are available for this component **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** + * @property uri - The URI of component entry. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** + * @property uriSchemas - The URI of component schemas. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** + * @property entries - All entry PatternFly documentation entries. + * @property versions - Entry segmented by versions. Contains all the same properties. */ type PatternFlyMcpResourceMetadata = { name: string; - urls: string[]; - urlsNoGuidance: string[]; - urlsGuidance: string[]; - entriesGuidance: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; - entriesNoGuidance: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; - versions: Record; + isSchemasAvailable: boolean | undefined; + uri: string | undefined; + uriSchemas: string | undefined; + entries: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; + versions: Record>; }; /** @@ -105,6 +118,7 @@ type PatternFlyMcpResourceMetadata = { * @property docsIndex - Patternfly available documentation index. * @property componentsIndex - Patternfly available components index. * @property keywordsIndex - Patternfly available keywords index. + * @property keywordsMap - Patternfly available keywords by resource name then by version. * @property isFallbackDocumentation - Whether the fallback documentation is used. * @property pathIndex - Patternfly documentation path index. * @property byPath - Patternfly documentation by path with entries @@ -117,14 +131,13 @@ interface PatternFlyMcpAvailableResources extends PatternFlyVersionContext { docsIndex: string[]; componentsIndex: string[]; keywordsIndex: string[]; + keywordsMap: PatternFlyMcpKeywordsMap; isFallbackDocumentation: boolean; pathIndex: string[]; byPath: PatternFlyMcpResourcesByPath; byUri: PatternFlyMcpResourcesByUri; byVersion: PatternFlyMcpResourcesByVersion; - byVersionComponentNames: { - [version: string]: string[]; - }; + byVersionComponentNames: PatternFlyMcpComponentNames['byVersion']; } /** @@ -137,7 +150,11 @@ const getPatternFlyDocsCatalog = async (): Promise { /** * A multifaceted list of all PatternFly React component names. * + * @note Consider iterating over the components by version using the "availableVersions" array + * from getPatternFlyVersionContext. + * * @note The "table" component is manually added to the `componentNamesIndex` list because it's not currently included - * in the component schemas package. + * in the component schemas v6 package. * * @note To avoid lookup issues we normalize all keys and indexes to lowercase. Component names are lowercased. * * @param contextPathOverride - Context path for updating the returned PatternFly versions. * @returns A multifaceted React component breakdown. Use the "memoized" property for performance. - * - `byVersion`: Map of lowercase PatternFly versions to lowercase component names. - * - `componentNamesIndex`: Latest PF version, lowercase component names sorted alphabetically. - * - `componentNamesWithSchemasIndex`: Latest PF version, lowercase component names sorted alphabetically. - * - `componentNamesWithSchemasMap`: Latest PF version, Map of lowercase component names to original case component names. + * - `componentNamesIndex`: ALL component names across ALL versions, + * - `componentNamesIndexMap`: Map of lowercase ALL component names to original case component names, + * - `byVersion`: Map of lowercase PatternFly versions to Map of lowercase component names to { isSchemasAvailable: boolean, displayName: string } */ -const getPatternFlyReactComponentNames = async (contextPathOverride?: string) => { - const { latestSchemasVersion, isEnvTheLatestSchemasVersion } = await getPatternFlyVersionContext.memo(contextPathOverride); - const byVersion = new Map(); +const getPatternFlyComponentNames = async (contextPathOverride?: string): Promise => { + const { latestSchemasVersion } = await getPatternFlyVersionContext.memo(contextPathOverride); const latestNamesIndex = [...Array.from(new Set([...pfComponentNames, 'Table'])).map(name => name.toLowerCase()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))]; - const latestNamesWithSchemaIndex = [...pfComponentNames.map(name => name.toLowerCase()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))]; - const latestNamesWithSchemasMap = new Map(pfComponentNames.map(name => [name.toLowerCase(), name])); + const latestNamesIndexMap = new Map(Array.from(new Set([...pfComponentNames, 'Table'])).map(name => [name.toLowerCase(), name])); + const latestByNameMap = new Map(); + + pfComponentNames.forEach(name => { + latestByNameMap.set(name.toLowerCase(), { isSchemasAvailable: true, displayName: name }); + }); + + const byVersion: PatternFlyMcpComponentNames['byVersion'] = new Map(); - byVersion.set(latestSchemasVersion, latestNamesIndex); + latestByNameMap.set('table', { isSchemasAvailable: false, displayName: 'Table' }); + const convert = Object.fromEntries(latestByNameMap); + + byVersion.set( + latestSchemasVersion, + convert + ); return { - byVersion: Object.fromEntries(byVersion), - componentNamesIndex: isEnvTheLatestSchemasVersion ? latestNamesIndex : [], - componentNamesWithSchemasIndex: isEnvTheLatestSchemasVersion ? latestNamesWithSchemaIndex : [], - componentNamesWithSchemasMap: isEnvTheLatestSchemasVersion ? Object.fromEntries(latestNamesWithSchemasMap) : {} + componentNamesIndex: latestNamesIndex, + componentNamesIndexMap: latestNamesIndexMap, + byVersion }; }; /** * Memoized version of getPatternFlyReactComponentNames. */ -getPatternFlyReactComponentNames.memo = memo(getPatternFlyReactComponentNames); +getPatternFlyComponentNames.memo = memo(getPatternFlyComponentNames); + +/** + * Filter keywords by removing noise words. + * + * @param keywordsMap - Available keywords by resource name. + * @param settings - Settings object + * @param settings.filterList - List of words to filter out from keywords. + */ +const filterKeywords = (keywordsMap: PatternFlyMcpKeywordsMap, { filterList = INDEX_NOISE_WORDS } = {}) => { + const filteredKeywords: PatternFlyMcpKeywordsMap = new Map(); + + for (const [keyword, versionMap] of keywordsMap) { + const updatedKeyword = keyword.toLowerCase().trim(); + const isVariant = filterList.some(word => { + const updatedWord = word.toLowerCase().trim(); + + return updatedKeyword === updatedWord || + // Prefix check. Is filterList word related? Loose distance check, 3 and below. + (updatedKeyword.startsWith(updatedWord) && updatedKeyword.replace(updatedWord, '').length < 4) || + // Suffix check. Is filterList word related? Loose distance check, 3 and below. + (updatedKeyword.endsWith(updatedWord) && updatedKeyword.replace(updatedWord, '').length < 4); + }); + + if (!isVariant) { + filteredKeywords.set(keyword, versionMap); + } + } + + return filteredKeywords; +}; + +/** + * Update the keywords map with the given keyword. + * + * @param keywordsMap - Available keywords by resource name. + * @param params - Params object + * @param params.keyword - Keyword to add to the map. + * @param params.name - Name of the resource associated with the keyword. + * @param params.version - Version of the resource associated with the keyword. + * @param settings - Settings object + * @param settings.blockList - List of words to block from indexing. + */ +const mutateKeyWordsMap = ( + keywordsMap: PatternFlyMcpKeywordsMap, + { keyword, name, version }: { keyword: string, name: string, version: string }, + { blockList = INDEX_BLOCKLIST_WORDS } = {} +) => { + const normalizedKeyword = keyword.toLowerCase().trim(); + const initialSplit = normalizedKeyword.split(' ').filter(Boolean); + const isMultipleWords = initialSplit.length > 1; + + const mutateMap = (word: string) => { + if (!keywordsMap.has(word)) { + keywordsMap.set(word, new Map()); + } + + const versionMap = keywordsMap.get(word); + + if (!versionMap?.has(version)) { + versionMap?.set(version, []); + } + + const mapped = versionMap?.get(version); + + if (!mapped?.includes(name)) { + mapped?.push(name); + } + }; + + // Break phrase apart + if (isMultipleWords) { + const splitKeywords = initialSplit.map(word => word.trim().replace(/[()|"'<>@#!,.;:]/g, '')); + + for (const word of splitKeywords) { + if (word.length <= 3 || blockList.find(blockedWord => blockedWord === word.toLowerCase())) { + continue; + } + + mutateMap(word); + } + } + + // But also apply entire phrases + mutateMap(normalizedKeyword); +}; /** * Get a multifaceted resources breakdown from PatternFly. @@ -236,8 +350,8 @@ getPatternFlyReactComponentNames.memo = memo(getPatternFlyReactComponentNames); */ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise => { const versionContext = await getPatternFlyVersionContext.memo(contextPathOverride); - const componentNames = await getPatternFlyReactComponentNames.memo(contextPathOverride); - const { byVersion: componentNamesByVersion, componentNamesIndex, componentNamesWithSchemasIndex: schemaNames } = componentNames; + const componentNames = await getPatternFlyComponentNames.memo(contextPathOverride); + const { componentNamesIndex, byVersion: componentNamesByVersion, byVersion: byVersionSchemaNames } = componentNames; const originalDocs = await getPatternFlyDocsCatalog.memo(); const resources = new Map(); @@ -245,22 +359,22 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const byUri: PatternFlyMcpResourcesByUri = {}; const byVersion: PatternFlyMcpResourcesByVersion = {}; const pathIndex = new Set(); + const rawKeywordsMap: PatternFlyMcpKeywordsMap = new Map(); Object.entries(originalDocs.docs).forEach(([docsName, entries]) => { const name = docsName.toLowerCase(); const resource: PatternFlyMcpResourceMetadata = { name, - urls: [], - urlsNoGuidance: [], - urlsGuidance: [], - entriesGuidance: [], - entriesNoGuidance: [], + isSchemasAvailable: undefined, + uri: undefined, + uriSchemas: undefined, + entries: [], versions: {} }; entries.forEach(entry => { const version = (entry.version || 'unknown').toLowerCase(); - const isSchemasAvailable = versionContext.latestSchemasVersion === version && schemaNames.includes(name); + const isSchemasAvailable = versionContext.latestSchemasVersion === version && byVersionSchemaNames.get(version)?.[name]?.isSchemasAvailable; const path = entry.path; const uri = `patternfly://docs/${version}/${name}`; @@ -270,11 +384,7 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< isSchemasAvailable, uri, uriSchemas: undefined, - urls: [], - urlsGuidance: [], - urlsNoGuidance: [], - entriesGuidance: [], - entriesNoGuidance: [] + entries: [] }; const displayCategory = setCategoryDisplayLabel(entry); @@ -301,20 +411,22 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< byVersion[version] ??= []; byVersion[version]?.push(extendedEntry); - resource.urls.push(path); - resource.versions[version].urls.push(path); - - if (extendedEntry.section === 'guidelines') { - resource.urlsGuidance.push(path); - resource.entriesGuidance.push(extendedEntry); - resource.versions[version].urlsGuidance.push(path); - resource.versions[version].entriesGuidance.push(extendedEntry); - } else { - resource.urlsNoGuidance.push(path); - resource.entriesNoGuidance.push(extendedEntry); - resource.versions[version].urlsNoGuidance.push(path); - resource.versions[version].entriesNoGuidance.push(extendedEntry); + mutateKeyWordsMap(rawKeywordsMap, { keyword: name, name, version }); + + if (entry.category) { + mutateKeyWordsMap(rawKeywordsMap, { keyword: entry.category, name, version }); + } + + if (entry.section) { + mutateKeyWordsMap(rawKeywordsMap, { keyword: entry.section, name, version }); } + + if (entry.description) { + mutateKeyWordsMap(rawKeywordsMap, { keyword: entry.description, name, version }); + } + + resource.entries.push(extendedEntry); + resource.versions[version].entries.push(extendedEntry); }); resources.set(name, resource); @@ -324,14 +436,19 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); }); + const filteredKeywords = filterKeywords(rawKeywordsMap); + return { ...versionContext, resources, docsIndex: Array.from(resources.keys()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), componentsIndex: componentNamesIndex, - keywordsIndex: Array.from(new Set([...Array.from(resources.keys()), ...componentNamesIndex])) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), isFallbackDocumentation: originalDocs.isFallback, + keywordsIndex: Array.from(new Set([ + ...componentNamesIndex, + ...Array.from(filteredKeywords.keys()) + ])).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), + keywordsMap: filteredKeywords, pathIndex: Array.from(pathIndex).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), byPath, byUri, @@ -352,10 +469,10 @@ getPatternFlyMcpResources.memo = memo(getPatternFlyMcpResources); * @returns The component schema, or `undefined` if the component name is not found. */ const getPatternFlyComponentSchema = async (componentName: string) => { - const { componentNamesWithSchemasMap } = await getPatternFlyReactComponentNames.memo(); + const { latestVersion, byVersionComponentNames } = await getPatternFlyMcpResources.memo(); try { - const updatedComponentName = componentNamesWithSchemasMap[componentName.toLowerCase()]; + const updatedComponentName = byVersionComponentNames.get(latestVersion)?.[componentName.toLowerCase()]?.displayName; if (!updatedComponentName) { return undefined; @@ -377,8 +494,10 @@ getPatternFlyComponentSchema.memo = memo(getPatternFlyComponentSchema, DEFAULT_O export { getPatternFlyComponentSchema, getPatternFlyMcpResources, - getPatternFlyReactComponentNames, + getPatternFlyComponentNames, setCategoryDisplayLabel, + type PatternFlyMcpComponentNames, + type PatternFlyMcpComponentNamesByVersion, type PatternFlyComponentSchema, type PatternFlyMcpAvailableResources, type PatternFlyMcpResourceMetadata, diff --git a/src/patternFly.helpers.ts b/src/patternFly.helpers.ts index b5a3d00..b670579 100644 --- a/src/patternFly.helpers.ts +++ b/src/patternFly.helpers.ts @@ -99,7 +99,11 @@ const getPatternFlyVersionContext = async ( getPatternFlyVersionContext.memo = memo(getPatternFlyVersionContext); /** - * Normalize the version string to a valid PatternFly `tag` display version, (e.g. "v4", "v5", "v6") + * Normalize the version string to an available and valid PatternFly `tag` display version, + * (e.g. "v4", "v5", "v6"). + * + * @note Only MCP server available versions are considered. Attempting to normalize a version that + * is not available will result in `undefined`. * * @param version - The version string to normalize. * @returns The normalized version string, or `undefined` if the version is not recognized. @@ -140,22 +144,6 @@ const normalizeEnumeratedPatternFlyVersion = async (version?: string) => { */ normalizeEnumeratedPatternFlyVersion.memo = memo(normalizeEnumeratedPatternFlyVersion); -/** - * Get all available PatternFly enumerations OR filter a version string to a valid PatternFly `tag` OR `display` version, - * (e.g. "current", "v6", etc.) - * - * @param version - The version string to filter. - * @returns If version is provided returns the filtered version string array, or all available versions if the version - * is not recognized. - */ -const filterEnumeratedPatternFlyVersions = async (version?: string) => { - const { enumeratedVersions } = await getPatternFlyVersionContext.memo(); - const normalizedVersion = await normalizeEnumeratedPatternFlyVersion.memo(version); - - return enumeratedVersions - .filter(version => version.toLowerCase().startsWith(normalizedVersion || '')); -}; - /** * Find the closest PatternFly version used within the project context. * @@ -206,7 +194,7 @@ const disabled_findClosestPatternFlyVersion = async ( isFuzzyMatch: true }); - for (const match of matches) { + for (const match of matches.results) { const versionMatch = matchPackageVersion(allDeps[match.item], availableVersions); if (versionMatch) { @@ -236,7 +224,6 @@ const disabled_findClosestPatternFlyVersion = async ( export { findClosestPatternFlyVersion, - filterEnumeratedPatternFlyVersions, disabled_findClosestPatternFlyVersion, getPatternFlyVersionContext, normalizeEnumeratedPatternFlyVersion, diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 8b6beb8..b36fbe8 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -1,13 +1,64 @@ -import { fuzzySearch, type FuzzySearchResult } from './server.search'; +import { fuzzySearch, type FuzzySearch, type FuzzySearchResult } from './server.search'; import { memo } from './server.caching'; import { DEFAULT_OPTIONS } from './options.defaults'; -import { getPatternFlyMcpResources, type PatternFlyMcpResourceMetadata } from './patternFly.getResources'; +import { + getPatternFlyMcpResources, + type PatternFlyMcpAvailableResources, + type PatternFlyMcpDocsMeta, + type PatternFlyMcpResourceMetadata +} from './patternFly.getResources'; +import { type PatternFlyMcpDocsCatalogDoc } from './docs.embedded'; /** - * Search result object returned by searchPatternFly. - * Includes additional metadata and URLs. + * A filtered MCP resource. + * + * @note Filtered resources lose their redundant version reference Map from `getPatternFlyMcpResources` + * to simplify filtering. This data is STILL available inside the resource metadata, but is + * potentially unnecessary since filtering already handles "version." + */ +type PatternFlyMcpResourceFilteredMetadata = Omit; + +/** + * Filters for specific properties of PatternFly data. + * + * @interface FilterPatternFlyFilters + * + * @property [version] - PatternFly version to filter search results. Defaults to undefined for all versions. + * @property [category] - Category to filter search results. Defaults to undefined for all categories. + * @property [section] - Section to filter search results. Defaults to undefined for all sections. + * @property [name] - Name to filter search results. Defaults to undefined for all names. + */ +interface FilterPatternFlyFilters { + version?: string | undefined; + category?: string | undefined; + section?: string | undefined; + name?: string | undefined; +} + +/** + * Result object returned by filterPatternFly. + * + * @interface FilterPatternFlyResults + * + * @property {PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta} byEntry - Array of filtered documentation entries. + * @property {Map} byResource - Map of filtered resources by resource name. */ -interface SearchPatternFlyResult extends FuzzySearchResult, PatternFlyMcpResourceMetadata { +interface FilterPatternFlyResults { + byEntry: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; + byResource: Map; +} + +/** + * Search result object returned by searchPatternFly. Includes additional metadata. + * + * @interface SearchPatternFlyResult + * + * @extends FuzzySearchResult + * @extends PatternFlyMcpResourceFilteredMetadata + * + * @property query - Search query used to generate the result. + */ +interface SearchPatternFlyResult extends FuzzySearchResult, PatternFlyMcpResourceFilteredMetadata { query: string; } @@ -18,67 +69,270 @@ interface SearchPatternFlyResult extends FuzzySearchResult, PatternFlyMcpResourc * @interface SearchPatternFlyResults * * @property isSearchWildCardAll - Whether the search query matched all components - * @property {SearchPatternFlyResult | undefined} firstExactMatch - First exact match within fuzzy search results - * @property {SearchPatternFlyResult[]} exactMatches - All exact matches within fuzzy search results - * @property {SearchPatternFlyResult[]} searchResults - Fuzzy search results + * @property {SearchPatternFlyResult | undefined} firstExactMatch - First exact match within search results + * @property {SearchPatternFlyResult[]} exactMatches - Exact matches within search results + * @property {SearchPatternFlyResult[]} remainingMatches - Contrast to `exactMatches`, the remaining matches within search results + * @property {SearchPatternFlyResult[]} searchResults - All search results, exact and remaining matches + * @property totalPotentialMatches - Total number of available PatternFly keywords to match on, what was possible before narrowing. + * @property totalResults - Total number of actual resources that meet all criteria. */ interface SearchPatternFlyResults { - isSearchWildCardAll: boolean, - firstExactMatch: SearchPatternFlyResult | undefined, - exactMatches: SearchPatternFlyResult[], - searchResults: SearchPatternFlyResult[] + isSearchWildCardAll: boolean; + firstExactMatch: SearchPatternFlyResult | undefined; + exactMatches: SearchPatternFlyResult[]; + remainingMatches: SearchPatternFlyResult[]; + searchResults: SearchPatternFlyResult[]; + totalPotentialMatches: number; + totalResults: number; } +/** + * Options for searchPatternFly. + * + * @interface SearchPatternFlyOptions + * + * @property {Promise} [mcpResources] - Object of multifaceted documentation entries to search. + * @property [allowWildCardAll] - Allow a search query to match all components. + * @property [maxDistance] - Maximum edit distance for fuzzy search. + * @property [maxResults] - Maximum number of results to return.lts. + */ +interface SearchPatternFlyOptions { + mcpResources?: Promise; + allowWildCardAll?: boolean; + maxDistance?: number; + maxResults?: number; +} + +/** + * Apply sequenced priority filters for predictable filtering, filter PatternFly data. + * + * @note This is a predictable filter, not a search. Use searchPatternFly for fuzzy search.` + * - Has case-insensitive filtering for all fields + * - Exact "version" filtering only + * - Has `prefix`, `suffix` filtering for any non-"version" field. + * + * @note Filter formats are generally assumed to be string values. If expanding to other types, ensure + * proper handling of non-string values. + * + * @param {FilterPatternFlyFilters} filters - Available filters for PatternFly data. + * @param [mcpResources] - An optional map of available PatternFly documentation entries to search. + * Internally, defaults to `getPatternFlyMcpResources.resources` + * @returns {Promise} - Filtered PatternFly results. + * - `byEntry`: Array of filtered documentation entries. + * - `byResource`: Map of filtered resources by resource name. + */ +const filterPatternFly = async ( + filters: FilterPatternFlyFilters | undefined, + mcpResources?: Promise | Map +): Promise => { + const getResources = await (mcpResources || getPatternFlyMcpResources.memo()); + const resources = (getResources as PatternFlyMcpAvailableResources)?.resources || + (getResources as Map); + + // Normalize filters - Currently, this is set to string filtering. Review expanding if/when necessary. + let updatedFilters: FilterPatternFlyFilters = {}; + + if (filters) { + // Allow strings and coerced numbers as strings + updatedFilters = Object.fromEntries( + Object.entries(filters) + .filter(([_key, value]) => (typeof value === 'string' || typeof value === 'number') && String(value).trim().length > 0) + .map(([key, value]) => [key, String(value).trim().toLowerCase()]) + ); + } + + // Filter matching for resources and entries + const byResource = new Map(); + const byEntry: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[] = []; + const filterMatch = (propertyValue: string | number | undefined, filterValue: string) => { + if (typeof propertyValue !== 'string' && typeof propertyValue !== 'number') { + return false; + } + + // Coerce potential numbers to strings + const normalizePropertyValue = String(propertyValue).trim().toLowerCase(); + + return normalizePropertyValue === filterValue || + normalizePropertyValue.startsWith(filterValue) || + normalizePropertyValue.endsWith(filterValue); + }; + + for (const [name, resource] of resources) { + const matchedEntries = resource.entries.filter(entry => { + const matchesVersion = !updatedFilters.version || entry.version.toLowerCase() === updatedFilters.version; + const matchesCategory = !updatedFilters.category || filterMatch(entry.category, updatedFilters.category); + const matchesSection = !updatedFilters.section || filterMatch(entry.section, updatedFilters.section); + const matchesName = !updatedFilters.name || filterMatch(entry.name, updatedFilters.name); + + // Any missing filter registers as true. Only filters that are active run their check. + return matchesVersion && matchesCategory && matchesSection && matchesName; + }); + + if (matchedEntries.length > 0) { + byEntry.push(...matchedEntries); + const { versions, ...filteredResource } = resource; + let versionContextualProperties = {}; + + // Apply version contextual properties, typically URIs + if (updatedFilters.version && versions?.[updatedFilters.version]) { + versionContextualProperties = { + isSchemasAvailable: versions[updatedFilters.version]?.isSchemasAvailable, + uri: versions[updatedFilters.version]?.uri, + uriSchemas: versions[updatedFilters.version]?.uriSchemas + }; + } + + byResource.set(name, { + ...filteredResource, + ...versionContextualProperties, + entries: matchedEntries + }); + } + } + + return { + byEntry, + byResource + }; +}; + +/** + * Memoized version of filterPatternFly + */ +filterPatternFly.memo = memo(filterPatternFly, DEFAULT_OPTIONS.resourceMemoOptions.default); + /** * Search for PatternFly component documentation URLs using fuzzy search. * + * @note Uses `filterPatternFly` for additional filtering. Future updates should + * consider moving the await outside the loop to improve performance, possibly a + * second iteration. + * * @param searchQuery - Search query string - * @param settings - Optional settings object - * @param settings.resources - Object of multifaceted documentation entries to search. - * @param settings.allowWildCardAll - Allow a search query to match all components. Defaults to false. + * @param {FilterPatternFlyFilters} filters - Available filters for PatternFly data. + * @param [settings] - Optional settings object + * @param [settings.mcpResources] - Optional function object of multifaceted documentation entries to search. + * Applied as a dependency to help with testing. Defaults to `getPatternFlyMcpResources` + * - `keywordsIndex`: Index of normalized keywords for fuzzy search + * - `keywordsMap`: Map of normalized keywords against versioned entries + * - `resources`: Map of names against entries + * @param [settings.allowWildCardAll] - Allow a search query to match all resources. Defaults to `false`. + * @param [settings.maxDistance] - Maximum edit distance for fuzzy search. Defaults to `3`. + * @param [settings.maxResults] - Maximum number of results to return. Defaults to `10`. * @returns Object containing search results and matched URLs - * - `isSearchWildCardAll`: Whether the search query matched all components - * - `firstExactMatch`: First exact match within fuzzy search results - * - `exactMatches`: All exact matches within fuzzy search results - * - `searchResults`: Fuzzy search results + * - `isSearchWildCardAll`: Whether the search query matched all resources + * - `firstExactMatch`: First exact match within search results + * - `exactMatches`: Exact matches within search results + * - `remainingMatches`: Contrast to `exactMatches`, the remaining matches within search results + * - `searchResults`: All search results, exact and remaining matches + * - `totalPotentialMatches`: Total number of available PatternFly keywords to match on, what was possible before narrowing. + * - `totalResults`: Total number of actual resources that meet all criteria. */ -const searchPatternFly = async (searchQuery: string, { - resources = getPatternFlyMcpResources.memo(), - allowWildCardAll = false -} = {}): Promise => { - const updatedResources = await resources; - const isWildCardAll = searchQuery.trim() === '*' || searchQuery.trim().toLowerCase() === 'all' || searchQuery.trim() === ''; +const searchPatternFly = async (searchQuery: string | number, filters?: FilterPatternFlyFilters | undefined, { + mcpResources, + allowWildCardAll = false, + maxDistance = 3, + maxResults = 10 +}: SearchPatternFlyOptions = {}): Promise => { + const coercedSearchQuery = String(searchQuery).trim(); + const updatedResources = await (mcpResources || getPatternFlyMcpResources.memo()); + const updatedFilters = filters || {}; + const isWildCardAll = coercedSearchQuery === '*' || coercedSearchQuery.toLowerCase() === 'all' || coercedSearchQuery === ''; const isSearchWildCardAll = allowWildCardAll && isWildCardAll; + let search: FuzzySearch | undefined; let searchResults: FuzzySearchResult[] = []; + // Perform wildcard all search or fuzzy search if (isSearchWildCardAll) { searchResults = updatedResources.keywordsIndex.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); } else { - searchResults = fuzzySearch(searchQuery, updatedResources.keywordsIndex, { - maxDistance: 3, - maxResults: 10, + // Pass the original searchQuery, fuzzySearch has its own normalization. + search = fuzzySearch(searchQuery, updatedResources.keywordsIndex, { + maxDistance, + maxResults, isFuzzyMatch: true, deduplicateByNormalized: true }); + + searchResults = search.results; } - const updatedSearchResults = searchResults.map((result: FuzzySearchResult) => { - const resource = updatedResources.resources.get(result.item); + // Store refined results in a map for easy "did we already find this?" checks" + const searchResultsMap = new Map(); + + // Refine search results with version filtering and mapping + for (const result of searchResults) { + const versionMap = updatedResources.keywordsMap.get(result.item); + + if (versionMap) { + const versionResults = updatedFilters.version ? versionMap.get(updatedFilters.version) : Array.from(versionMap.values()).flat(); + + if (versionResults) { + for (const name of versionResults) { + const namedResource = updatedResources.resources.get(name); + + if (!namedResource || searchResultsMap.has(name)) { + continue; + } + + // Omit versions from the result + const { versions, ...filteredResource } = namedResource; + + // Apply contextual filtering and flattening + const { byResource } = await filterPatternFly(updatedFilters, new Map([[name, { ...filteredResource }]])); - return { - ...result, - ...resource, - query: searchQuery - }; - }) as SearchPatternFlyResult[]; + if (!byResource.has(name)) { + continue; + } + + let versionContextualProperties; + + // Apply version contextual properties, typically URIs + if (updatedFilters.version && versions[updatedFilters.version]) { + versionContextualProperties = { + isSchemasAvailable: versions[updatedFilters.version]?.isSchemasAvailable, + uri: versions[updatedFilters.version]?.uri, + uriSchemas: versions[updatedFilters.version]?.uriSchemas + }; + } + + // Apply property filters + searchResultsMap.set(name, { + ...result, + ...byResource.get(name), + ...versionContextualProperties, + query: coercedSearchQuery + } as SearchPatternFlyResult); + } + } + } + } + + // Minor breakdown of search results + const exactMatches = Array.from(searchResultsMap.values()).filter(result => result.matchType === 'exact' || result.matchType === 'all'); + const remainingMatches = Array.from(searchResultsMap.values()).filter(result => result.matchType !== 'exact' && result.matchType !== 'all'); + + // Sort by distance then name + const sortByDistanceByName = (a: SearchPatternFlyResult, b: SearchPatternFlyResult) => { + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + return a.name.localeCompare(b.name); + }; - const exactMatches = updatedSearchResults.filter(result => result.matchType === 'exact' || result.matchType === 'all'); + const sortedExactMatches = exactMatches.sort(sortByDistanceByName); + const sortedRemainingMatches = remainingMatches.sort(sortByDistanceByName); + const sortedSearchResults = Array.from(searchResultsMap.values()).sort(sortByDistanceByName); return { isSearchWildCardAll, - firstExactMatch: exactMatches[0], - exactMatches, - searchResults: updatedSearchResults + firstExactMatch: sortedExactMatches[0], + exactMatches: sortedExactMatches.slice(0, maxResults), + remainingMatches: (maxResults - exactMatches.length) < 0 ? [] : sortedRemainingMatches.slice(0, maxResults - exactMatches.length), + searchResults: sortedSearchResults.slice(0, maxResults), + totalResults: sortedSearchResults.length, + totalPotentialMatches: search?.totalResults ?? updatedResources.keywordsIndex.length }; }; @@ -88,7 +342,10 @@ const searchPatternFly = async (searchQuery: string, { searchPatternFly.memo = memo(searchPatternFly, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs); export { + filterPatternFly, searchPatternFly, + type FilterPatternFlyFilters, + type FilterPatternFlyResults, type SearchPatternFlyResult, type SearchPatternFlyResults }; diff --git a/src/server.search.ts b/src/server.search.ts index 32e80a3..f07a3df 100644 --- a/src/server.search.ts +++ b/src/server.search.ts @@ -5,24 +5,48 @@ import { memo } from './server.caching'; * normalizeString function interface */ interface NormalizeString { - (str: string): string; - memo: (str: string) => string; + (str: string | number): string; + memo: (str: string | number) => string; } /** * Options for closest search */ interface ClosestSearchOptions { - normalizeFn?: (str: string) => string; + normalizeFn?: (str: string | number) => string; } +/** + * Fuzzy search result match types. + */ +type FuzzySearchResultMatchType = 'exact' | 'prefix' | 'suffix' | 'contains' | 'partial' | 'fuzzy' | 'all'; + /** * Fuzzy search result using fastest-levenshtein + * + * @property item - The matched string item from the search. + * @property distance - The numerical representation of similarity between the search query and the item. + * @property {FuzzySearchResultMatchType} matchType - The categorization of the match, indicating the nature of the similarity. */ -interface FuzzySearchResult { +type FuzzySearchResult = { item: string; distance: number; - matchType: 'exact' | 'prefix' | 'suffix' | 'contains' | 'partial' | 'fuzzy' | 'all'; + matchType: FuzzySearchResultMatchType; +}; + +/** + * Fuzzy search result using fastest-levenshtein + * + * @interface FuzzySearchResult + * + * @property {FuzzySearchResult[]} results - Array of search results + * @property totalResults - Total number of results actually found. + * @property totalResultsReturned - Total number of results returned based on settings. + */ +interface FuzzySearch { + results: FuzzySearchResult[], + totalResults: number; + totalResultsReturned: number; } /** @@ -41,9 +65,10 @@ interface FuzzySearchResult { * - `deduplicateByNormalized` - If true, deduplicate results by normalized value instead of original string (default: false) */ interface FuzzySearchOptions { + allowEmptyQuery?: boolean; maxDistance?: number; maxResults?: number; - normalizeFn?: (str: string) => string; + normalizeFn?: (str: string | number) => string; isExactMatch?: boolean; isPrefixMatch?: boolean; isSuffixMatch?: boolean; @@ -63,7 +88,7 @@ interface FuzzySearchOptions { * @param str * @returns Normalized or empty string */ -const normalizeString: NormalizeString = (str: string) => String(str || '') +const normalizeString: NormalizeString = (str: string | number) => String(str ?? '') .trim() .toLowerCase() .normalize('NFKD') @@ -84,10 +109,10 @@ normalizeString.memo = memo(normalizeString, { cacheLimit: 50 }); * - For multiple matches, use `fuzzySearch` instead. * - Null/undefined items are normalized to empty strings to prevent runtime errors. * - * @param query - Search query string - * @param items - Array of strings to search + * @param query - Search query string or number + * @param items - Array of strings and/or numbers to search * @param {ClosestSearchOptions} options - Search configuration options - * @returns {string | null} Closest matching string or null + * @returns Closest matching string or number or null * * @example * ```typescript @@ -96,8 +121,8 @@ normalizeString.memo = memo(normalizeString, { cacheLimit: 50 }); * ``` */ const findClosest = ( - query: string, - items: string[] = [], + query: string | number, + items: (string | number)[] = [], { normalizeFn = normalizeString.memo }: ClosestSearchOptions = {} @@ -108,7 +133,7 @@ const findClosest = ( return null; } - const normalizedItems = items.map(item => (item ? normalizeFn(item) : '')); + const normalizedItems = items.map(item => normalizeFn(item)); const closestMatch = closest(normalizedQuery, normalizedItems); return items[normalizedItems.indexOf(closestMatch)] || null; @@ -124,20 +149,24 @@ const findClosest = ( * - Negative `maxDistance` values intentionally filter out all results, including exact matches. * - Empty-query fallback is allowed when `isFuzzyMatch` is true (items with length <= maxDistance can match). * - * @param query - Search query string - * @param items - Array of strings to search + * @param query - Search query string or number + * @param items - Array of strings and/or numbers to search * @param {FuzzySearchOptions} options - Search configuration options - * @param {number} options.maxDistance - Maximum edit distance for a match. Distance is defined as - * @param {number} options.maxResults - Maximum number of results to return + * @param options.allowEmptyQuery - Allow empty queries to match items with length <= maxDistance (default: `false`) + * @param options.maxDistance - Maximum edit distance for a match. Distance is defined as + * @param options.maxResults - Maximum number of results to return * @param {NormalizeString} options.normalizeFn - Function to normalize strings. Should always return a string or empty string (default: `normalizeString`) - * @param {boolean} options.isExactMatch - Include exact matches in results (default: `true`) - * @param {boolean} options.isPrefixMatch - Include prefix matches in results (default: `true`) - * @param {boolean} options.isSuffixMatch - Include suffix matches in results (default: `true`) - * @param {boolean} options.isContainsMatch - Include contains matches in results (default: `true`) - * @param {boolean} options.isPartialMatch - Include partial matches in results (default: `true`) - * @param {boolean} options.isFuzzyMatch - Allow fuzzy matches even when `maxDistance` is negative or zero. - * @param {boolean} options.deduplicateByNormalized - If `true`, deduplicate results by normalized value instead of original string. - * @returns {FuzzySearchResult[]} Array of matching strings with distance and match type + * @param options.isExactMatch - Include exact matches in results (default: `true`) + * @param options.isPrefixMatch - Include prefix matches in results (default: `true`) + * @param options.isSuffixMatch - Include suffix matches in results (default: `true`) + * @param options.isContainsMatch - Include contains matches in results (default: `true`) + * @param options.isPartialMatch - Include partial matches in results (default: `true`) + * @param options.isFuzzyMatch - Allow fuzzy matches even when `maxDistance` is negative or zero. + * @param options.deduplicateByNormalized - If `true`, deduplicate results by normalized value instead of original string. + * @returns {FuzzySearch} An object containing search results with distance and match type + * - `results`: Array of matching strings with distance and match type. + * - `totalResults`: Total number of results found. + * - `totalReturnedResults`: Total number of results returned (after applying maxResults limit). * * @example * ```typescript @@ -145,13 +174,14 @@ const findClosest = ( * maxDistance: 3, * maxResults: 5 * }); - * // Returns: [{ item: 'Button', distance: 0, matchType: 'exact' }, ...] + * // Returns: { results: [{ item: 'Button', distance: 0, matchType: 'exact' }, ...], totalResults: 15, totalReturnedResults: 5 } * ``` */ const fuzzySearch = ( - query: string, - items: string[] = [], + query: string | number, + items: (string | number)[] = [], { + allowEmptyQuery = false, maxDistance = 3, maxResults = 10, normalizeFn = normalizeString.memo, @@ -163,14 +193,14 @@ const fuzzySearch = ( isFuzzyMatch = false, deduplicateByNormalized = false }: FuzzySearchOptions = {} -): FuzzySearchResult[] => { +): FuzzySearch => { const normalizedQuery = normalizeFn(query); const seenItem = new Set(); const results: FuzzySearchResult[] = []; items?.forEach(item => { const normalizedItem = normalizeFn(item); - const deduplicationKey = deduplicateByNormalized ? normalizedItem : item; + const deduplicationKey = deduplicateByNormalized ? normalizedItem : String(item); if (seenItem.has(deduplicationKey)) { return; @@ -178,7 +208,7 @@ const fuzzySearch = ( seenItem.add(deduplicationKey); let editDistance = 0; - let matchType: FuzzySearchResult['matchType'] | undefined; + let matchType: FuzzySearchResultMatchType | undefined; if (normalizedItem === normalizedQuery) { matchType = 'exact'; @@ -194,9 +224,13 @@ const fuzzySearch = ( } else if (normalizedQuery !== '' && normalizedItem !== '' && normalizedQuery.includes(normalizedItem)) { matchType = 'partial'; editDistance = 2; - } else if (isFuzzyMatch && Math.abs(normalizedItem.length - normalizedQuery.length) <= maxDistance) { - matchType = 'fuzzy'; - editDistance = distance(normalizedQuery, normalizedItem); + } else if (isFuzzyMatch && (allowEmptyQuery || (normalizedQuery !== '' && normalizedItem !== ''))) { + const checkDistance = distance(normalizedItem, normalizedQuery); + + if (checkDistance <= maxDistance) { + matchType = 'fuzzy'; + editDistance = checkDistance; + } } if (matchType === undefined) { @@ -212,7 +246,7 @@ const fuzzySearch = ( if (editDistance <= maxDistance && isIncluded) { results.push({ - item, + item: String(item), distance: editDistance, matchType }); @@ -228,7 +262,11 @@ const fuzzySearch = ( return a.item.localeCompare(b.item); }); - return results.slice(0, maxResults); + return { + results: results.slice(0, maxResults), + totalResults: results.length, + totalResultsReturned: results.slice(0, maxResults).length + }; }; export { @@ -237,6 +275,8 @@ export { findClosest, type NormalizeString, type ClosestSearchOptions, + type FuzzySearch, type FuzzySearchResult, + type FuzzySearchResultMatchType, type FuzzySearchOptions }; diff --git a/src/tool.componentSchemas.ts b/src/tool.componentSchemas.ts index 7da0323..f007531 100644 --- a/src/tool.componentSchemas.ts +++ b/src/tool.componentSchemas.ts @@ -45,7 +45,7 @@ const componentSchemasTool = (options = getOptions()): McpTool => { } // Use fuzzySearch with `isFuzzyMatch` to handle exact and intentional suggestions in one pass - const results = fuzzySearch(componentName, componentNames, { + const { results } = fuzzySearch(componentName, componentNames, { maxDistance: 3, maxResults: 5, isFuzzyMatch: true, diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts index facbbeb..37b408c 100644 --- a/src/tool.searchPatternFlyDocs.ts +++ b/src/tool.searchPatternFlyDocs.ts @@ -89,7 +89,7 @@ const setComponentToDocsMap = () => { if (urls.includes(value)) { return key; } else { - const results = fuzzySearch(value, urls, { + const { results } = fuzzySearch(value, urls, { deduplicateByNormalized: true }); @@ -146,12 +146,14 @@ const searchComponents = (searchQuery: string, { names = componentNames, allowWi if (isSearchWildCardAll) { searchResults = componentNames.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); } else { - searchResults = fuzzySearch(searchQuery, names, { + const search = fuzzySearch(searchQuery, names, { maxDistance: 3, maxResults: 10, isFuzzyMatch: true, deduplicateByNormalized: true }); + + searchResults = search.results; } const extendResults = (results: FuzzySearchResult[] = []) => results.map(result => {