Skip to content
This repository has been archived by the owner on Dec 8, 2023. It is now read-only.

Commit

Permalink
feat: add search ranking
Browse files Browse the repository at this point in the history
  • Loading branch information
aswanson-nr committed Aug 23, 2022
1 parent f4647d9 commit 25101ee
Show file tree
Hide file tree
Showing 6 changed files with 387 additions and 24 deletions.
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = {
transform: {
'^.+\\.js$': '<rootDir>/jest-preprocess.js',
'.(ts|tsx)': 'ts-jest',
},
testPathIgnorePatterns: ['node_modules', '\\.cache/'],
globals: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"eslint-plugin-markdown": "^2.2.1",
"jest": "^26.4.0",
"prettier": "2.2.1",
"ts-jest": "^26.4.0",
"tsc": "^2.0.4",
"typescript": "^4.7.4"
}
Expand Down
249 changes: 249 additions & 0 deletions src/utils/__tests__/searchRanking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
'use strict';
import {
rank,
exactMatch,
startsWith,
containsWord,
containsSubString,
getTitle,
getKeywords,
getDescription,
ruleScore,
} from '../searchRanking';

describe('searchRanking', () => {
describe('matchers', () => {
describe('exactMatch', () => {
test('handles null values', () => {
expect(exactMatch(null, 'hello')).toBe(false);
expect(exactMatch('hello', null)).toBe(false);
});

test('handles undefined values', () => {
expect(exactMatch(undefined, 'hello')).toBe(false);
expect(exactMatch('hello', undefined)).toBe(false);
});

test('matches exactly', () => {
expect(exactMatch('hello', 'hello')).toBe(true);
});

test('matches case', () => {
expect(exactMatch('Hello', 'hello')).toBe(false);
});

test('does not match substring', () => {
expect(exactMatch('hello', 'hel')).toBe(false);
});
});

describe('startsWith', () => {
test('handles null values', () => {
expect(startsWith(null, 'hello')).toBe(false);
expect(startsWith('hello', null)).toBe(false);
});

test('handles undefined values', () => {
expect(startsWith(undefined, 'hello')).toBe(false);
expect(startsWith('hello', undefined)).toBe(false);
});

test('matches full value', () => {
expect(startsWith('hello', 'hello')).toBe(true);
});

test('matches start of string', () => {
expect(startsWith('hello', 'hel')).toBe(true);
});

test('does not match substring', () => {
expect(startsWith('hello', 'ell')).toBe(false);
});
});

describe('containsWord', () => {
test('handles null values', () => {
expect(containsWord(null, 'hello')).toBe(false);
expect(containsWord('hello', null)).toBe(false);
});

test('handles undefined values', () => {
expect(containsWord(undefined, 'hello')).toBe(false);
expect(containsWord('hello', undefined)).toBe(false);
});

test('matches word with surrounding spaces', () => {
expect(containsWord('hi hello world', 'hello')).toBe(true);
});

test('matches start of string', () => {
expect(containsWord('hello world', 'hello')).toBe(true);
});

test('matches end of string', () => {
expect(containsWord('hello world', 'world')).toBe(true);
});

test('does not match substring', () => {
expect(containsWord('hihelloworld', 'hello')).toBe(false);
});

test('escapes regex characters in search string', () => {
expect(containsWord('hi hello world', 'hello(/[]')).toBe(false);
expect(containsWord('hi hello world', '^hello$')).toBe(false);
expect(containsWord('hi hello world', '^he/llo$')).toBe(false);
expect(containsWord('hi hello world', '^he/l(lo)$')).toBe(false);
expect(containsWord('hi hello world', '^he[a-z]$')).toBe(false);
expect(containsWord('hi hello world', '^he[0-9]$')).toBe(false);
});
});

describe('containsSubString', () => {
test('handles null values', () => {
expect(containsSubString(null, 'hello')).toBe(false);
expect(containsSubString('hello', null)).toBe(false);
});

test('handles undefined values', () => {
expect(containsSubString(undefined, 'hello')).toBe(false);
expect(containsSubString('hello', undefined)).toBe(false);
});

test('matches word with surrounding spaces', () => {
expect(containsSubString('hi hello world', 'hello')).toBe(true);
});

test('matches start of string', () => {
expect(containsSubString('hello world', 'hello')).toBe(true);
});

test('matches end of string', () => {
expect(containsSubString('hello world', 'world')).toBe(true);
});

test('matches substring', () => {
expect(containsSubString('hihelloworld', 'hello')).toBe(true);
});
});
});

describe('getters', () => {
describe('getTitle', () => {
test('returns title if it exists', () => {
const qs = {
title: 'test',
};

expect(getTitle(qs)).toEqual('test');
});

test('returns empty string if title does not exist', () => {
expect(getTitle({})).toEqual('');
});
});

describe('getKeywords', () => {
test('returns keywords if they exists', () => {
const qs = {
keywords: ['test', 'test2'],
};

expect(getKeywords(qs)).toEqual(['test', 'test2']);
});

test('returns empty array if keywords do not exist', () => {
expect(getKeywords({})).toEqual([]);
});
});
describe('getDescription', () => {
test('returns description if it exists', () => {
const qs = {
description: 'test description',
};

expect(getDescription(qs)).toEqual('test description');
});

test('returns empty string if the description does not exist', () => {
expect(getDescription({})).toEqual('');
});
});
});

describe('ranking', () => {
describe('ruleScore', () => {
test('returns correct score for index', () => {
// current ruleset has 7 rules
expect(ruleScore(0)).toBe(49);
expect(ruleScore(1)).toBe(36);
expect(ruleScore(2)).toBe(25);
expect(ruleScore(3)).toBe(16);
expect(ruleScore(4)).toBe(9);
expect(ruleScore(5)).toBe(4);
expect(ruleScore(6)).toBe(1);
});
});

describe('rank', () => {
test('empty search string', () => {
const qs = {
title: 'test',
description: 'testestest',
keywords: [],
};
// this is a weird case since the empty string can match a lot of stuff
// it isn't a huge problem regarding actual search ranking since we won't be applying
// searches for the empty string
//
// matches rules
// title - startsWith
// title - containsWord
// title - containsSubString
// description - containsWord
// description - containsSubString
expect(rank(qs, '')).toEqual(75);
});

test('handles undefined search string', () => {
const qs = {
title: 'test',
description: 'testestest',
keywords: [],
};
expect(rank(qs, undefined)).toEqual(0);
});

test('ranks title higher', () => {
const qs = {
title: 'test',
description: '',
keywords: [],
};
const qs2 = {
title: 'blah',
description: 'test',
keywords: [],
};
expect(rank(qs, 'test')).toBeGreaterThan(rank(qs2, 'test'));
});

test('ranks keywords', () => {
const qs = {
title: 'test',
description: '',
keywords: ['banana', 'watermelon'],
};
expect(rank(qs, 'banana')).toBe(16);
});

test('trims search term', () => {
const qs = {
title: 'test',
description: '',
keywords: ['banana', 'watermelon'],
};
expect(rank(qs, ' banana ')).toBe(16);
});
});
});
});
7 changes: 5 additions & 2 deletions src/utils/allFilteredQuickstarts.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CATEGORIES from '@data/instant-observability-categories';
import { rank } from '@utils/searchRanking';

/**
* Callback function for alphabetical sort.
Expand Down Expand Up @@ -85,14 +86,16 @@ const filterByCategory = (category) => {
* @param {Array} array of quickstarts
*/
const allFilteredQuickstarts = (quickstarts, search, category) => {
const trimmedSearch = search.trim();
const filterQuickstartsByKeyword = filterQuickstarts(quickstarts);
const featuredQuickstarts = filterQuickstartsByKeyword('featured');
const mostPopularQuickstarts = filterQuickstartsByKeyword('most popular');
const sortedQuickstarts = quickstarts.sort(alphaSort).sort(shiftCodestream);

const filteredQuickstarts = sortedQuickstarts
.filter(filterBySearch(search))
.filter(filterByCategory(category));
.filter(filterBySearch(trimmedSearch))
.filter(filterByCategory(category))
.sort((a, b) => rank(b, trimmedSearch) - rank(a, trimmedSearch));

const categoriesWithCount = CATEGORIES.map((cat) => ({
...cat,
Expand Down
81 changes: 81 additions & 0 deletions src/utils/searchRanking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
type QuickstartPartial = {
title: string;
keywords: string[];
description: string;
};

type Rule = {
getter: (a: QuickstartPartial) => string | string[];
matcher: (a: string, b: string) => boolean;
};

const escapeForRegex = (s: string): string =>
s?.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') ?? s;

export const exactMatch = (s: string, searchString: string): boolean =>
s === searchString;

export const startsWith = (s: string, searchString: string): boolean =>
s?.startsWith(searchString) ?? false;

export const containsWord = (s: string, searchString: string): boolean =>
new RegExp(`\\b${escapeForRegex(searchString)}\\b`).test(s);

export const containsSubString = (s: string, searchString: string): boolean =>
s?.includes(searchString) ?? false;

export const getTitle = (quickstart: QuickstartPartial): string =>
quickstart?.title ?? '';

export const getKeywords = (quickstart: QuickstartPartial): string[] =>
quickstart?.keywords ?? [];

export const getDescription = (quickstart: QuickstartPartial): string =>
quickstart?.description ?? '';

/**
* These are the rules used to rank a quickstart based on the search string.
* Each rule is weighted based on its position in the array.
* Ex: ruleScore(0) = (7 - 0)^2 = 49
* Ex: ruleScore(5) = (7 - 5)^2 = 4
*/
const rules: Rule[] = [
{ getter: getTitle, matcher: exactMatch },
{ getter: getTitle, matcher: startsWith },
{ getter: getTitle, matcher: containsWord },
{ getter: getKeywords, matcher: exactMatch },
{ getter: getDescription, matcher: containsWord },
{ getter: getTitle, matcher: containsSubString },
{ getter: getDescription, matcher: containsSubString },
];

export const ruleScore = (index: number): number =>
Math.pow(rules.length - index, 2);

type RankFuncSignature = (
quickstart: QuickstartPartial,
searchString: string
) => number;

/**
* Returns a numerical value for a quickstart based on the search string
* for the purposes of ranking search results.
*/
export const rank: RankFuncSignature = (quickstart, searchString) => {
const total = rules.reduce((score, { getter, matcher }, index) => {
const quickstartField = getter(quickstart);
const lowerSearch = searchString?.toLowerCase()?.trim() ?? '';

const matches = Array.isArray(quickstartField)
? quickstartField.some((val) => matcher(val.toLowerCase(), lowerSearch))
: matcher(quickstartField.toLowerCase(), searchString);

if (matches) {
return score + ruleScore(index);
} else {
return score;
}
}, 0);

return total;
};
Loading

0 comments on commit 25101ee

Please sign in to comment.