UNCLASSIFIED - NO CUI

Skip to content
Snippets Groups Projects
Commit 700a25f2 authored by graham.smith's avatar graham.smith Committed by Michelle Tran
Browse files

Bull 1847

parent accdc0de
No related branches found
No related tags found
1 merge request!188Bull 1847
Showing with 2227 additions and 939 deletions
......@@ -51,9 +51,9 @@ For all below check the following breakpoints for Vue apps
#### Developer responsibilities with Cypress tests
- [ ] Make sure any new download buttons (that don't use DownloadButton.vue) has the attribute `data-cy="download"`.
- [ ] Make sure any new download buttons (that don't use DownloadButton.vue) have the attribute `data-cy="download"`.
- [ ] Make sure new pages / paths within router.js are added to the links.js test suite.
- [ ] Add a new test case for within downloads.js if you're adding the `first` download button to any page.
- [ ] Add a new test case within downloads.js if you're adding the _first_ download button to any page.
- [ ] Reuse existing custom commands if they serve the purpose of your test.
- [ ] Create a new custom command for any duplicate code / test that can be `globally` reused.
- [ ] Create a new custom command for any duplicate code / test that can be _globally_ reused.
- [ ] Create a local function for duplicate code that can be reused within your test spec.
Source diff could not be displayed: it is too large. Options to address this: view the blob.
......@@ -17,8 +17,6 @@
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
"@vue/babel-preset-jsx": "^1.1.2",
"arrows-svg": "^1.1.1",
"core-js": "^3.6.4",
"cross-env": "^7.0.3",
......@@ -37,16 +35,17 @@
"vuetify": "^2.4.11"
},
"devDependencies": {
"@babel/node": "^7.18.5",
"@babel/core": "7.17.9",
"@babel/node": "^7.17.9",
"@babel/plugin-transform-strict-mode": "^7.8.3",
"@cypress/webpack-preprocessor": "^5.12.0",
"@mdi/font": "^5.3.45",
"@vue/cli-plugin-babel": "~4.3.0",
"@vue/cli-plugin-babel": "^4.5.19",
"@vue/cli-plugin-e2e-cypress": "^4.5.19",
"@vue/cli-plugin-eslint": "~4.3.0",
"@vue/cli-plugin-router": "~4.3.0",
"@vue/cli-plugin-unit-jest": "^4.4.1",
"@vue/cli-service": "~4.5.17",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-plugin-router": "^4.5.19",
"@vue/cli-plugin-unit-jest": "^4.5.19",
"@vue/cli-service": "^4.5.19",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/test-utils": "^1.0.3",
"babel-eslint": "^10.1.0",
......@@ -73,9 +72,7 @@
"vuetify-loader": "^1.3.0"
},
"overrides": {
"minimist": "^1.2.5",
"http-proxy": "^1.18.1",
"yargs-parser": "^18.1.3"
"@babel/generator": "7.17.9"
},
"eslintConfig": {
"root": true,
......
......@@ -5,10 +5,7 @@ const puppeteer = require("puppeteer");
const http = require("http");
const portfinder = require("portfinder");
const { routes } = require("../src/router/routes");
const {
useAcronymAliases,
configureCustomTokenizer,
} = require("../src/plugins/search/lunr-pipelines");
const { useAcronymAliases } = require("../src/plugins/search/lunr-pipelines");
// collect routes that we want to include in the search index
const routesMap = routes.reduce((map, route) => {
......@@ -85,13 +82,11 @@ const getRoutesData = async (baseUrl) => {
* @returns {lunr} lunrjs index
*/
const createLunrIndex = (routeData) => {
configureCustomTokenizer();
return lunr(function () {
// index fields
this.ref("id");
this.field("path", { boost: 5 });
this.field("title", { boost: 15 });
this.field("title", { boost: 50 });
this.field("description", { boost: 10 });
this.field("content");
this.metadataWhitelist = ["position"];
......
......@@ -111,4 +111,59 @@ export default class Search {
return context;
}
/**
* A lunrjs search result can contain multiple context matches. This function returns the "best"
* match to be used for displaying content context in the ui. "Best" is defined as the token match
* that has the most unique number of matches on the page.
* For example, a search of "platform one big bang" has 4 tokens. If a page matches the "platform"
* token once, but "big" 9 times, it makes more sense to use the context of "big" to show a more
* meaningful context to the user.
* @param {Object} context single context object (from SearchApi)
* @returns {String} the best context to display in the ui (or null if none found)
*/
getBestContentContext(context) {
let bestContent = null;
// loop through all context matches (corresponds to a token match)
// to find the one with the most content matches
for (const contextMatch of Object.values(context)) {
if (
contextMatch.content &&
(!bestContent || contextMatch.content.length > bestContent.length)
) {
bestContent = contextMatch.content;
}
}
// return the first context if a best content object was found
if (bestContent && bestContent.length > 0) {
return bestContent[0].context;
}
return null;
}
/**
* Adds html highlights (<em> tags) around context matches.
* @param {Array} contextMatches array of context matches for a given search result (expected to be generated from the SearchApi)
* @param {String} contextText the base context text to update
* @returns {String} the updated content text, including new highlights
*/
highlight(contextMatches, contextText) {
// base case, account for no context matches
if (contextMatches.length === 0) {
return "";
}
// additional base case, if contextText is blank we'll use the first context
let updatedContextText = contextText || contextMatches[0].context;
// wrap all match text with the highlight tag
for (const contextMatch of contextMatches) {
updatedContextText = updatedContextText.replace(
contextMatch.match,
`<em>${contextMatch.match}</em>`
);
}
return updatedContextText;
}
}
This diff is collapsed.
This diff is collapsed.
<template>
<div class="nav-bar-section">
<v-app-bar id="app-bar" app dark height="102">
<v-app-bar
id="app-bar"
app
dark
height="102"
:class="{
'hide-nav-toolbar': showBurgerIcon,
'search-expanded': isSearchExpanded,
}"
>
<v-toolbar-title>
<router-link to="/" class="d-flex">
<router-link to="/" class="d-flex" data-cy="nav-logo">
<YodaLogo data-cy="v-img" id="p1-nav-logo" class="my-auto" />
</router-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items v-show="!burgerMenu">
<div class="navbar-nav my-auto">
<v-toolbar-items>
<div class="navbar-nav my-auto pr-10">
<router-link class="nav-item" to="/" exact> Home </router-link>
<v-menu open-on-hover bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
<span class="pt-1" v-bind="attrs" v-on="on">
<router-link class="nav-item d-inline" to="/services">
Services
<v-icon class="navbar-chevron-icon">mdi-chevron-down</v-icon>
......@@ -32,7 +41,6 @@
</v-list-item>
</v-list>
</v-menu>
<router-link class="nav-item" to="/resellers">
Resellers
</router-link>
......@@ -48,11 +56,20 @@
</div>
</v-toolbar-items>
<Search
@expanded="
isSearchExpanded = true;
menuVisible = false;
"
@collapsed="isSearchExpanded = false"
:full-width="showBurgerIcon"
/>
<v-app-bar-nav-icon
id="nav-toggle"
@click.stop="menuVisible = !menuVisible"
class="mr-0"
v-show="burgerMenu"
v-show="showBurgerIcon"
>
<div class="burger burger-squeeze" :class="{ open: menuVisible }">
<div class="burger-lines"></div>
......@@ -148,11 +165,13 @@
</template>
<script>
import YodaLogo from "@/assets/images/logos/Logo_P1_Yoda_Campfire-WH.svg";
import Search from "@/components/Search.vue";
import { routesByName } from "@/router/routes";
export default {
name: "NavBar",
components: {
Search,
YodaLogo,
},
......@@ -165,8 +184,9 @@ export default {
routesByName.CNAP,
routesByName.Cybersecurity,
],
navigationDrawerBreakPoint: 1045,
burgerMenu: false,
navigationDrawerBreakPoint: 1150,
showBurgerIcon: false,
isSearchExpanded: false,
}),
beforeDestroy() {
if (typeof window === "undefined") return;
......@@ -181,10 +201,9 @@ export default {
},
methods: {
onResize() {
this.burgerMenu = window.innerWidth < this.navigationDrawerBreakPoint;
this.showBurgerIcon = window.innerWidth < this.navigationDrawerBreakPoint;
},
},
watch: {
"$vuetify.breakpoint.width": function (value) {
if (value >= this.navigationDrawerBreakPoint) {
......@@ -241,13 +260,13 @@ a.nav-item.router-link-active {
}
}
.navbar-icon-cursor {
cursor: pointer;
}
.navbar-nav {
font-weight: 600;
.nav-item {
opacity: 1;
visibility: visible;
transition: opacity 0.25s ease-out, visibility 0.25s ease-out;
padding: 0.5rem;
margin: 0 0.5rem;
color: $light-text-color !important;
......@@ -288,8 +307,31 @@ a.nav-item.router-link-active {
padding-right: 1rem;
z-index: 10;
&.search-expanded {
.nav-item {
visibility: hidden;
opacity: 0;
transition: opacity 0.25s ease-out, visibility 0.25s ease-out;
}
}
&.hide-nav-toolbar {
.navbar-nav {
display: none;
}
&.search-expanded {
#p1-nav-logo,
#nav-toggle {
visibility: hidden;
opacity: 0;
transition: opacity 0.25s ease-out, visibility 0.25s ease-out;
}
}
}
#p1-nav-logo {
width: 245px;
min-width: 245px;
}
#nav-toggle {
......@@ -299,6 +341,7 @@ a.nav-item.router-link-active {
@include xs {
#p1-nav-logo {
width: 180px;
min-width: 180px;
}
}
......
<template>
<div
class="search-container h-100 d-flex align-items-center"
:class="{ 'full-width': fullWidth, closed: !isExpanded }"
>
<v-text-field
placeholder="Search"
data-cy="search"
filled
dense
clearable
full-width
:hide-details="true"
class="expanding-search mt-n1"
v-model="searchText"
prepend-inner-icon="mdi-magnify"
@focus="expand"
@blur="blur"
@input="change"
:loading="searchLoading"
>
<template v-slot:progress>
<v-progress-linear
indeterminate
absolute
color="primary"
></v-progress-linear>
</template>
</v-text-field>
<div class="results-container">
<div
data-cy="emptySearchResults"
class="empty-results"
v-if="
searchText &&
!searchLoading &&
displayResults &&
displayResults.length === 0
"
>
No search results found
</div>
<div
data-cy="searchResults"
class="results"
v-else-if="displayResults && displayResults.length > 0"
>
<template v-for="(result, index) in displayResults">
<div class="result" :key="index">
<a class="results-link" :href="result.url">
<cite>{{ result.url }}</cite>
<span class="title">{{ result.title }}</span>
</a>
<div
class="results-description text-left"
v-if="result.match.description"
v-html="result.match.description"
></div>
<div
class="results-content text-left"
v-if="result.match.content"
v-html="result.match.content"
></div>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
import Search from "@/api/search.js";
import { routesByPath } from "@/router/routes.js";
export default {
name: "SearchComponent",
props: {
fullWidth: {
type: Boolean,
default: false,
},
},
data: () => ({
searchClosed: true,
searchText: null,
searchLoading: false,
debounce: null,
searchApi: null,
displayResults: null,
}),
mounted() {
this.searchApi = new Search();
},
methods: {
expand() {
this.searchClosed = false;
this.$emit("expanded");
},
blur() {
this.searchClosed = true;
this.searchLoading = false;
if (!this.isExpanded) {
this.$emit("collapsed");
}
},
change() {
// cancel any pending search (debounce)
if (this.debounce) {
clearTimeout(this.debounce);
}
// empty text short-circuit
if (!this.searchText) {
this.displayResults = [];
return;
}
this.searchLoading = true;
// debounce the call to search to only execute once per time window
this.debounce = setTimeout(async () => {
this.search();
this.searchLoading = false;
}, 300);
},
/**
* Perform a search using the search api
*/
search() {
const searchResults = this.searchApi.search(this.searchText);
this.displayResults = searchResults.map((result) => ({
url: `${window.location.origin}${result.ref}`,
title:
routesByPath[result.ref]?.meta.title ||
routesByPath[result.ref]?.header?.title,
match: this.getMatch(result),
}));
},
/**
* Logic to calculate the match details of a specific result to display.
*
* @returns {Object} matchResult
* @returns {String} matchResult.content - html string for content, including highlights
* @returns {String} matchResult.description - html string for description, including highlights
*/
getMatch(result) {
const context = this.searchApi.getContext(result, 140);
const bestContentContext = this.searchApi.getBestContentContext(context);
let content = bestContentContext;
let description = "";
for (const matchMeta of Object.values(context)) {
if (matchMeta.content) {
content = this.searchApi.highlight(matchMeta.content, content);
}
if (matchMeta.description) {
description = this.searchApi.highlight(
matchMeta.description,
description
);
}
}
return {
content,
description,
};
},
},
computed: {
isExpanded() {
return !this.searchClosed || this.searchText;
},
},
};
</script>
<style lang="scss" scoped>
.search-container {
&.full-width {
.expanding-search,
.results-container {
max-width: 100%;
}
}
.expanding-search,
.results-container {
position: absolute;
right: 0;
width: 100%;
max-width: 800px;
}
&.full-width.closed {
.expanding-search {
right: 64px;
}
}
&.closed {
.expanding-search {
max-width: 45px;
:deep(.v-input__slot) {
cursor: pointer !important;
background: transparent !important;
:deep(.v-input__icon--append) {
display: none;
}
}
}
}
.expanding-search {
transition: max-width 0.25s;
min-width: 45px;
:deep(.v-input__slot) {
&:before,
&:after {
border-color: transparent !important;
background-color: transparent;
}
}
}
.results-container {
top: 68px;
background-color: #021827;
max-height: 80vh;
overflow-y: scroll;
padding-left: 16px;
padding-right: 16px;
.results > .result {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 32px;
margin-bottom: 32px;
&:first-child {
margin-top: 16px;
}
&:last-child {
margin-bottom: 16px;
}
color: #fcfafa;
.results-link {
text-align: left;
&:hover {
color: lighten($link-color, 10%) !important;
}
cite {
font-style: normal;
color: #fcfafa;
display: block;
font-size: 14px;
}
}
.results-content {
color: #bdc1c6;
}
:deep(em) {
font-weight: bold;
font-style: normal;
color: $p1-light-green;
}
}
}
}
</style>
......@@ -69,26 +69,3 @@ export const useAcronymAliases = (builder) => {
builder.pipeline.before(lunr.stemmer, acronymAliases);
builder.searchPipeline.before(lunr.stemmer, acronymAliases);
};
/**
* Applied a custom tokenizer that treats the title field as one token, meaning that the default
* tokenization method of splitting the title field on spaces will not be used
*/
export const configureCustomTokenizer = () => {
const defaultTokenizer = lunr.tokenizer;
const defaultTokenSeparator = lunr.tokenizer.separator;
lunr.tokenizer = (obj, metadata) => {
// index the entire title as one token instead of splitting it
if (metadata.fields.length === 1 && metadata.fields.includes("title")) {
const str = obj.toString().toLowerCase();
const tokenMetadata = lunr.utils.clone(metadata) || {};
tokenMetadata["position"] = [0, str.length];
tokenMetadata["index"] = 0;
return [new lunr.Token(str, tokenMetadata)];
}
// use the default tokenizer (spaces) for everything else
return defaultTokenizer(obj, metadata);
};
lunr.tokenizer.separator = defaultTokenSeparator;
};
......@@ -284,3 +284,8 @@ export const routesByName = routes.reduce((all, route) => {
all[route.name] = route;
return all;
}, {});
export const routesByPath = routes.reduce((all, route) => {
all[route.path] = route;
return all;
}, {});
describe("Testing Search", () => {
before(() => {
cy.viewport(1024, 768);
cy.visit("/");
});
it("should expand search bar", () => {
cy.get('[data-cy="search"]').should("exist").focus();
// check expand state hides the nav
cy.get('[data-cy="nav-logo"]').should("not.be.visible");
});
it("should show empty search results", () => {
cy.get('[data-cy="search"]').should("exist").focus();
cy.get('[data-cy="search"]').type("dummytokenthatshouldnotmatchanyresults");
cy.get('[data-cy="emptySearchResults"]')
.should("exist")
.should("contain", "No search results found");
cy.get('[data-cy="searchResults"]').should("not.exist");
cy.get('[data-cy="search"]').get(".mdi-close").click();
});
it("should show search results", () => {
cy.get('[data-cy="search"]').should("exist").focus();
cy.get('[data-cy="search"]').type("platform");
cy.get('[data-cy="emptySearchResults"]').should("not.exist");
cy.get('[data-cy="searchResults"]').should("exist");
cy.get('[data-cy="search"]').get(".mdi-close").click();
});
});
import Search from "@/api/search.js";
describe("search api", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("should pass search query to lunrjs index", () => {
const api = new Search();
api.index = {
search: jest.fn(),
};
const mockQuery = "mock query";
api.search(mockQuery);
expect(api.index.search).toBeCalledTimes(1);
expect(api.index.search).toHaveBeenCalledWith(mockQuery);
});
describe("context", () => {
describe("highlight", () => {
it("should use <em> tags to wrap in context highlight", () => {
const api = new Search();
const highlight = api.highlight([{ match: "test" }], "this is a test");
expect(highlight).toEqual("this is a <em>test</em>");
});
it("should return empty string if no context matches", () => {
const api = new Search();
const highlight = api.highlight([], "this is a test");
expect(highlight).toEqual("");
});
it("should use first context match text if no context text", () => {
const api = new Search();
const highlight = api.highlight([
{ match: "test", context: "this is a test" },
]);
expect(highlight).toEqual("this is a <em>test</em>");
});
});
describe("getBestContentContext", () => {
it("should compute best content context based on array length", () => {
const api = new Search();
const mockContext = {
token1: { content: [] },
token2: {
content: [
{ context: "token2 test content" },
{ context: "token3 test content - 2" },
],
},
token3: { content: [{ context: "token3 test content" }] },
};
const bestContentContext = api.getBestContentContext(mockContext);
expect(bestContentContext).toEqual(
mockContext.token2.content[0].context
);
});
it("should return null if empty context provided", () => {
const api = new Search();
const mockContext = {};
const bestContentContext = api.getBestContentContext(mockContext);
expect(bestContentContext).toBeNull();
});
});
it("should compute context", () => {
const api = new Search();
api.contextData = {
mock: {
id: "-mock-",
path: "-mock-",
title: "mock title",
description: "description mock",
content: "content mock content",
},
};
const mockSearchResult = {
ref: "mock",
matchData: {
metadata: {
mock: {
title: { position: [[0, 4]] },
description: { position: [[12, 4]] },
content: { position: [[8, 4]] },
path: { position: [[1, 4]] },
},
},
},
};
const context = api.getContext(mockSearchResult);
expect(context).toEqual({
mock: {
content: [{ context: "content mock content", match: "mock" }],
description: [{ context: "description mock", match: "mock" }],
path: [{ context: "-mock-", match: "mock" }],
title: [{ context: "mock title", match: "mock" }],
},
});
});
});
});
import { mount, createLocalVue } from "@vue/test-utils";
import SearchComponent from "@/components/Search.vue";
import Vue from "vue";
import Vuetify from "vuetify";
Vue.use(Vuetify);
jest.useFakeTimers();
describe("Search.vue", () => {
const localVue = createLocalVue();
let vuetify;
let wrapper;
beforeEach(() => {
vuetify = new Vuetify();
wrapper = mount(SearchComponent, { localVue, vuetify });
});
afterEach(() => {
jest.clearAllMocks();
});
describe("expand/collapse", () => {
it("should emit event when search expanded", async () => {
wrapper.vm.expand();
await wrapper.vm.$nextTick(); // wait until $emits have been handled
expect(wrapper.emitted().expanded).toBeTruthy();
expect(wrapper.emitted().collapsed).toBeFalsy();
});
it("should emit event when search collapsed", async () => {
wrapper.vm.blur();
await wrapper.vm.$nextTick(); // wait until $emits have been handled
expect(wrapper.emitted().collapsed).toBeTruthy();
expect(wrapper.emitted().expanded).toBeFalsy();
});
it("should not emit event when search blurred if searchText exists", async () => {
wrapper.vm.searchText = "mock";
wrapper.vm.blur();
await wrapper.vm.$nextTick(); // wait until $emits have been handled
expect(wrapper.emitted().collapsed).toBeFalsy();
expect(wrapper.emitted().expanded).toBeFalsy();
});
});
describe("search", () => {
it("should debounce search", async () => {
const spy = jest.spyOn(wrapper.vm, "search");
wrapper.vm.searchText = "mock";
// multiple calls withing the debounce window...
wrapper.vm.change();
wrapper.vm.change();
wrapper.vm.change();
jest.runAllTimers();
// should result in only one call to search
expect(spy.mock.calls.length).toEqual(1);
});
it("should not run a search on empty searchText", async () => {
const spy = jest.spyOn(wrapper.vm, "search");
wrapper.vm.searchText = "";
wrapper.vm.change();
jest.runAllTimers();
expect(spy.mock.calls.length).toEqual(0);
});
it("should set loading state", async () => {
wrapper.vm.searchText = "mock";
wrapper.vm.change();
expect(wrapper.vm.searchLoading).toEqual(true);
jest.runAllTimers();
expect(wrapper.vm.searchLoading).toEqual(false);
});
it("should compute displayResults on search", async () => {
const mockResults = [
{
ref: "mock-a",
matchData: {
metadata: {
content: [],
description: [],
title: [],
},
},
},
];
const mockContext = {
mocking: {
content: [{ match: "mock", context: "this is mock content" }],
description: [
{ match: "mock", context: "this is a mock description" },
],
},
mocked: {
title: [{ match: "mock", context: "this is a mock title" }],
},
};
delete window.location;
window.location = new URL("https://www.example.com/");
wrapper.vm.searchApi.search = jest.fn().mockReturnValue(mockResults);
wrapper.vm.searchApi.getContext = jest.fn().mockReturnValue(mockContext);
wrapper.vm.searchText = "mock";
wrapper.vm.search();
expect(wrapper.vm.searchApi.search).toHaveBeenCalledTimes(1);
expect(wrapper.vm.searchApi.search).toHaveBeenCalledWith(
wrapper.vm.searchText
);
expect(wrapper.vm.displayResults.length).toEqual(1);
expect(wrapper.vm.displayResults[0].url).toEqual(
window.location.origin + mockResults[0].ref
);
expect(wrapper.vm.displayResults[0].match.content).toEqual(
"this is <em>mock</em> content"
);
expect(wrapper.vm.displayResults[0].match.description).toEqual(
"this is a <em>mock</em> description"
);
});
});
});
import lunr from "lunr";
import {
acronymAliases,
registerAcronymAliases,
useAcronymAliases,
} from "@/plugins/search/lunr-pipelines";
describe("lunr-pipelins.js", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("should update token with acronym alias", () => {
const mockToken = {
toString: () => "cso",
update: jest.fn(),
};
acronymAliases(mockToken);
expect(mockToken.update).toHaveBeenCalledTimes(1);
const updateFn = mockToken.update.mock.calls[0][0];
expect(typeof updateFn).toEqual("function");
expect(updateFn()).toEqual("chief software officer");
});
it("should not update token with alias if no matching alias", () => {
const mockToken = {
toString: () => "mock",
update: jest.fn(),
};
acronymAliases(mockToken);
expect(mockToken.update).toHaveBeenCalledTimes(0);
});
it("should only register acronymAliases once", () => {
const spy = jest.spyOn(lunr.Pipeline, "registerFunction");
registerAcronymAliases();
registerAcronymAliases();
expect(spy).toBeCalledTimes(1);
});
it("should configure lunrjs builder", () => {
const mockBuilder = {
pipeline: { before: jest.fn() },
searchPipeline: { before: jest.fn() },
};
useAcronymAliases(mockBuilder);
expect(mockBuilder.pipeline.before).toHaveBeenCalledTimes(1);
expect(mockBuilder.pipeline.before).toHaveBeenCalledWith(
lunr.stemmer,
acronymAliases
);
expect(mockBuilder.searchPipeline.before).toHaveBeenCalledTimes(1);
expect(mockBuilder.searchPipeline.before).toHaveBeenCalledWith(
lunr.stemmer,
acronymAliases
);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment