Browse Source

lunr search 2.3.1

windhamdavid 2 years ago
parent
commit
a0bcc97656

+ 1 - 1
package-lock.json

@@ -13,7 +13,7 @@
         "@docusaurus/theme-mermaid": "^2.2.0",
         "@mdx-js/react": "^1.6.22",
         "clsx": "^1.1.1",
-        "docusaurus-lunr-search": "^2.1.15",
+        "docusaurus-lunr-search": "^2.3.1",
         "docusaurus-plugin-matomo": "^0.0.6",
         "react": "^17.0.2",
         "react-dom": "^17.0.2"

+ 1 - 1
package.json

@@ -19,7 +19,7 @@
     "@docusaurus/theme-mermaid": "^2.2.0",
     "@mdx-js/react": "^1.6.22",
     "clsx": "^1.1.1",
-    "docusaurus-lunr-search": "^2.1.15",
+    "docusaurus-lunr-search": "^2.3.1",
     "docusaurus-plugin-matomo": "^0.0.6",
     "react": "^17.0.2",
     "react-dom": "^17.0.2"

+ 297 - 0
src/theme/SearchBar/DocSearch.js

@@ -0,0 +1,297 @@
+import Hogan from "hogan.js";
+import LunrSearchAdapter from "./lunar-search";
+import autocomplete from "autocomplete.js";
+import templates from "./templates";
+import utils from "./utils";
+import $ from "autocomplete.js/zepto";
+
+class DocSearch {
+    constructor({
+        searchDocs,
+        searchIndex,
+        inputSelector,
+        debug = false,
+        baseUrl = '/',
+        queryDataCallback = null,
+        autocompleteOptions = {
+            debug: false,
+            hint: false,
+            autoselect: true
+        },
+        transformData = false,
+        queryHook = false,
+        handleSelected = false,
+        enhancedSearchInput = false,
+        layout = "collumns"
+    }) {
+        this.input = DocSearch.getInputFromSelector(inputSelector);
+        this.queryDataCallback = queryDataCallback || null;
+        const autocompleteOptionsDebug =
+            autocompleteOptions && autocompleteOptions.debug
+                ? autocompleteOptions.debug
+                : false;
+        // eslint-disable-next-line no-param-reassign
+        autocompleteOptions.debug = debug || autocompleteOptionsDebug;
+        this.autocompleteOptions = autocompleteOptions;
+        this.autocompleteOptions.cssClasses =
+            this.autocompleteOptions.cssClasses || {};
+        this.autocompleteOptions.cssClasses.prefix =
+            this.autocompleteOptions.cssClasses.prefix || "ds";
+        const inputAriaLabel =
+            this.input &&
+            typeof this.input.attr === "function" &&
+            this.input.attr("aria-label");
+        this.autocompleteOptions.ariaLabel =
+            this.autocompleteOptions.ariaLabel || inputAriaLabel || "search input";
+
+        this.isSimpleLayout = layout === "simple";
+
+        this.client = new LunrSearchAdapter(searchDocs, searchIndex, baseUrl);
+
+        if (enhancedSearchInput) {
+            this.input = DocSearch.injectSearchBox(this.input);
+        }
+        this.autocomplete = autocomplete(this.input, autocompleteOptions, [
+            {
+                source: this.getAutocompleteSource(transformData, queryHook),
+                templates: {
+                    suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout),
+                    footer: templates.footer,
+                    empty: DocSearch.getEmptyTemplate()
+                }
+            }
+        ]);
+
+        const customHandleSelected = handleSelected;
+        this.handleSelected = customHandleSelected || this.handleSelected;
+
+        // We prevent default link clicking if a custom handleSelected is defined
+        if (customHandleSelected) {
+            $(".algolia-autocomplete").on("click", ".ds-suggestions a", event => {
+                event.preventDefault();
+            });
+        }
+
+        this.autocomplete.on(
+            "autocomplete:selected",
+            this.handleSelected.bind(null, this.autocomplete.autocomplete)
+        );
+
+        this.autocomplete.on(
+            "autocomplete:shown",
+            this.handleShown.bind(null, this.input)
+        );
+
+        if (enhancedSearchInput) {
+            DocSearch.bindSearchBoxEvent();
+        }
+    }
+
+    static injectSearchBox(input) {
+        input.before(templates.searchBox);
+        const newInput = input
+            .prev()
+            .prev()
+            .find("input");
+        input.remove();
+        return newInput;
+    }
+
+    static bindSearchBoxEvent() {
+        $('.searchbox [type="reset"]').on("click", function () {
+            $("input#docsearch").focus();
+            $(this).addClass("hide");
+            autocomplete.autocomplete.setVal("");
+        });
+
+        $("input#docsearch").on("keyup", () => {
+            const searchbox = document.querySelector("input#docsearch");
+            const reset = document.querySelector('.searchbox [type="reset"]');
+            reset.className = "searchbox__reset";
+            if (searchbox.value.length === 0) {
+                reset.className += " hide";
+            }
+        });
+    }
+
+    /**
+     * Returns the matching input from a CSS selector, null if none matches
+     * @function getInputFromSelector
+     * @param  {string} selector CSS selector that matches the search
+     * input of the page
+     * @returns {void}
+     */
+    static getInputFromSelector(selector) {
+        const input = $(selector).filter("input");
+        return input.length ? $(input[0]) : null;
+    }
+
+    /**
+     * Returns the `source` method to be passed to autocomplete.js. It will query
+     * the Algolia index and call the callbacks with the formatted hits.
+     * @function getAutocompleteSource
+     * @param  {function} transformData An optional function to transform the hits
+     * @param {function} queryHook An optional function to transform the query
+     * @returns {function} Method to be passed as the `source` option of
+     * autocomplete
+     */
+    getAutocompleteSource(transformData, queryHook) {
+        return (query, callback) => {
+            if (queryHook) {
+                // eslint-disable-next-line no-param-reassign
+                query = queryHook(query) || query;
+            }
+            this.client.search(query).then(hits => {
+                if (
+                    this.queryDataCallback &&
+                    typeof this.queryDataCallback == "function"
+                ) {
+                    this.queryDataCallback(hits);
+                }
+                if (transformData) {
+                    hits = transformData(hits) || hits;
+                }
+                callback(DocSearch.formatHits(hits));
+            });
+        };
+    }
+
+    // Given a list of hits returned by the API, will reformat them to be used in
+    // a Hogan template
+    static formatHits(receivedHits) {
+        const clonedHits = utils.deepClone(receivedHits);
+        const hits = clonedHits.map(hit => {
+            if (hit._highlightResult) {
+                // eslint-disable-next-line no-param-reassign
+                hit._highlightResult = utils.mergeKeyWithParent(
+                    hit._highlightResult,
+                    "hierarchy"
+                );
+            }
+            return utils.mergeKeyWithParent(hit, "hierarchy");
+        });
+
+        // Group hits by category / subcategory
+        let groupedHits = utils.groupBy(hits, "lvl0");
+        $.each(groupedHits, (level, collection) => {
+            const groupedHitsByLvl1 = utils.groupBy(collection, "lvl1");
+            const flattenedHits = utils.flattenAndFlagFirst(
+                groupedHitsByLvl1,
+                "isSubCategoryHeader"
+            );
+            groupedHits[level] = flattenedHits;
+        });
+        groupedHits = utils.flattenAndFlagFirst(groupedHits, "isCategoryHeader");
+
+        // Translate hits into smaller objects to be send to the template
+        return groupedHits.map(hit => {
+            const url = DocSearch.formatURL(hit);
+            const category = utils.getHighlightedValue(hit, "lvl0");
+            const subcategory = utils.getHighlightedValue(hit, "lvl1") || category;
+            const displayTitle = utils
+                .compact([
+                    utils.getHighlightedValue(hit, "lvl2") || subcategory,
+                    utils.getHighlightedValue(hit, "lvl3"),
+                    utils.getHighlightedValue(hit, "lvl4"),
+                    utils.getHighlightedValue(hit, "lvl5"),
+                    utils.getHighlightedValue(hit, "lvl6")
+                ])
+                .join(
+                    '<span class="aa-suggestion-title-separator" aria-hidden="true"> › </span>'
+                );
+            const text = utils.getSnippetedValue(hit, "content");
+            const isTextOrSubcategoryNonEmpty =
+                (subcategory && subcategory !== "") ||
+                (displayTitle && displayTitle !== "");
+            const isLvl1EmptyOrDuplicate =
+                !subcategory || subcategory === "" || subcategory === category;
+            const isLvl2 =
+                displayTitle && displayTitle !== "" && displayTitle !== subcategory;
+            const isLvl1 =
+                !isLvl2 &&
+                (subcategory && subcategory !== "" && subcategory !== category);
+            const isLvl0 = !isLvl1 && !isLvl2;
+
+            return {
+                isLvl0,
+                isLvl1,
+                isLvl2,
+                isLvl1EmptyOrDuplicate,
+                isCategoryHeader: hit.isCategoryHeader,
+                isSubCategoryHeader: hit.isSubCategoryHeader,
+                isTextOrSubcategoryNonEmpty,
+                category,
+                subcategory,
+                title: displayTitle,
+                text,
+                url
+            };
+        });
+    }
+
+    static formatURL(hit) {
+        const { url, anchor } = hit;
+        if (url) {
+            const containsAnchor = url.indexOf("#") !== -1;
+            if (containsAnchor) return url;
+            else if (anchor) return `${hit.url}#${hit.anchor}`;
+            return url;
+        } else if (anchor) return `#${hit.anchor}`;
+        /* eslint-disable */
+        console.warn("no anchor nor url for : ", JSON.stringify(hit));
+        /* eslint-enable */
+        return null;
+    }
+
+    static getEmptyTemplate() {
+        return args => Hogan.compile(templates.empty).render(args);
+    }
+
+    static getSuggestionTemplate(isSimpleLayout) {
+        const stringTemplate = isSimpleLayout
+            ? templates.suggestionSimple
+            : templates.suggestion;
+        const template = Hogan.compile(stringTemplate);
+        return suggestion => template.render(suggestion);
+    }
+
+    handleSelected(input, event, suggestion, datasetNumber, context = {}) {
+        // Do nothing if click on the suggestion, as it's already a <a href>, the
+        // browser will take care of it. This allow Ctrl-Clicking on results and not
+        // having the main window being redirected as well
+        if (context.selectionMethod === "click") {
+            return;
+        }
+
+        input.setVal("");
+        window.location.assign(suggestion.url);
+    }
+
+    handleShown(input) {
+        const middleOfInput = input.offset().left + input.width() / 2;
+        let middleOfWindow = $(document).width() / 2;
+
+        if (isNaN(middleOfWindow)) {
+            middleOfWindow = 900;
+        }
+
+        const alignClass =
+            middleOfInput - middleOfWindow >= 0
+                ? "algolia-autocomplete-right"
+                : "algolia-autocomplete-left";
+        const otherAlignClass =
+            middleOfInput - middleOfWindow < 0
+                ? "algolia-autocomplete-right"
+                : "algolia-autocomplete-left";
+        const autocompleteWrapper = $(".algolia-autocomplete");
+        if (!autocompleteWrapper.hasClass(alignClass)) {
+            autocompleteWrapper.addClass(alignClass);
+        }
+
+        if (autocompleteWrapper.hasClass(otherAlignClass)) {
+            autocompleteWrapper.removeClass(otherAlignClass);
+        }
+    }
+}
+
+export default DocSearch;

+ 0 - 7
src/theme/SearchBar/algolia.css

@@ -1,10 +1,3 @@
-/**
- * Copyright (c) 2017-present, Facebook, Inc.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */
-
 /* Bottom border of each suggestion */
 .algolia-docsearch-suggestion {
   border-bottom-color: #3a3dd1;

+ 8 - 12
src/theme/SearchBar/index.js

@@ -1,31 +1,27 @@
-/**
- * Copyright (c) 2017-present, Facebook, Inc.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */
-
 import React, { useRef, useCallback, useState } from "react";
 import classnames from "classnames";
 import { useHistory } from "@docusaurus/router";
 import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
 import { usePluginData } from '@docusaurus/useGlobalData';
+import useIsBrowser from "@docusaurus/useIsBrowser";
 const Search = props => {
   const initialized = useRef(false);
   const searchBarRef = useRef(null);
   const [indexReady, setIndexReady] = useState(false);
   const history = useHistory();
-  const { siteConfig = {}, isClient = false} = useDocusaurusContext();
+  const { siteConfig = {} } = useDocusaurusContext();
+  const isBrowser = useIsBrowser();
   const { baseUrl } = siteConfig;
   const initAlgolia = (searchDocs, searchIndex, DocSearch) => {
       new DocSearch({
         searchDocs,
         searchIndex,
+        baseUrl,
         inputSelector: "#search_input_react",
         // Override algolia's default selection event, allowing us to do client-side
         // navigation and avoiding a full page refresh.
         handleSelected: (_input, _event, suggestion) => {
-          const url = baseUrl + suggestion.url;
+          const url = suggestion.url || "/";
           // Use an anchor tag to parse the absolute url into a relative url
           // Alternatively, we can use new URL(suggestion.url) but its not supported in IE
           const a = document.createElement("a");
@@ -54,10 +50,10 @@ const Search = props => {
       Promise.all([
         getSearchDoc(),
         getLunrIndex(),
-        import("./lib/DocSearch"),
+        import("./DocSearch"),
         import("./algolia.css")
       ]).then(([searchDocs, searchIndex, { default: DocSearch }]) => {
-        if( searchDocs.length === 0) {
+        if (searchDocs.length === 0) {
           return;
         }
         initAlgolia(searchDocs, searchIndex, DocSearch);
@@ -78,7 +74,7 @@ const Search = props => {
     [props.isSearchBarExpanded]
   );
 
-  if (isClient) {
+  if (isBrowser) {
     loadAlgolia();
   }
 

+ 147 - 0
src/theme/SearchBar/lunar-search.js

@@ -0,0 +1,147 @@
+import lunr from "@generated/lunr.client";
+lunr.tokenizer.separator = /[\s\-/]+/;
+
+class LunrSearchAdapter {
+    constructor(searchDocs, searchIndex, baseUrl = '/') {
+        this.searchDocs = searchDocs;
+        this.lunrIndex = lunr.Index.load(searchIndex);
+        this.baseUrl = baseUrl;
+    }
+
+    getLunrResult(input) {
+        return this.lunrIndex.query(function (query) {
+            const tokens = lunr.tokenizer(input);
+            query.term(tokens, {
+                boost: 10
+            });
+            query.term(tokens, {
+                wildcard: lunr.Query.wildcard.TRAILING
+            });
+        });
+    }
+
+    getHit(doc, formattedTitle, formattedContent) {
+        return {
+            hierarchy: {
+                lvl0: doc.pageTitle || doc.title,
+                lvl1: doc.type === 0 ? null : doc.title
+            },
+            url: this.baseUrl !== '/' || doc.url.charAt(0) !== '/' ? this.baseUrl + doc.url : doc.url,
+            _snippetResult: formattedContent ? {
+                content: {
+                    value: formattedContent,
+                    matchLevel: "full"
+                }
+            } : null,
+            _highlightResult: {
+                hierarchy: {
+                    lvl0: {
+                        value: doc.type === 0 ? formattedTitle || doc.title : doc.pageTitle,
+                    },
+                    lvl1:
+                        doc.type === 0
+                            ? null
+                            : {
+                                value: formattedTitle || doc.title
+                            }
+                }
+            }
+        };
+    }
+    getTitleHit(doc, position, length) {
+        const start = position[0];
+        const end = position[0] + length;
+        let formattedTitle = doc.title.substring(0, start) + '<span class="algolia-docsearch-suggestion--highlight">' + doc.title.substring(start, end) + '</span>' + doc.title.substring(end, doc.title.length);
+        return this.getHit(doc, formattedTitle)
+    }
+
+    getKeywordHit(doc, position, length) {
+        const start = position[0];
+        const end = position[0] + length;
+        let formattedTitle = doc.title + '<br /><i>Keywords: ' + doc.keywords.substring(0, start) + '<span class="algolia-docsearch-suggestion--highlight">' + doc.keywords.substring(start, end) + '</span>' + doc.keywords.substring(end, doc.keywords.length) + '</i>'
+        return this.getHit(doc, formattedTitle)
+    }
+
+    getContentHit(doc, position) {
+        const start = position[0];
+        const end = position[0] + position[1];
+        let previewStart = start;
+        let previewEnd = end;
+        let ellipsesBefore = true;
+        let ellipsesAfter = true;
+        for (let k = 0; k < 3; k++) {
+            const nextSpace = doc.content.lastIndexOf(' ', previewStart - 2);
+            const nextDot = doc.content.lastIndexOf('.', previewStart - 2);
+            if ((nextDot > 0) && (nextDot > nextSpace)) {
+                previewStart = nextDot + 1;
+                ellipsesBefore = false;
+                break;
+            }
+            if (nextSpace < 0) {
+                previewStart = 0;
+                ellipsesBefore = false;
+                break;
+            }
+            previewStart = nextSpace + 1;
+        }
+        for (let k = 0; k < 10; k++) {
+            const nextSpace = doc.content.indexOf(' ', previewEnd + 1);
+            const nextDot = doc.content.indexOf('.', previewEnd + 1);
+            if ((nextDot > 0) && (nextDot < nextSpace)) {
+                previewEnd = nextDot;
+                ellipsesAfter = false;
+                break;
+            }
+            if (nextSpace < 0) {
+                previewEnd = doc.content.length;
+                ellipsesAfter = false;
+                break;
+            }
+            previewEnd = nextSpace;
+        }
+        let preview = doc.content.substring(previewStart, start);
+        if (ellipsesBefore) {
+            preview = '... ' + preview;
+        }
+        preview += '<span class="algolia-docsearch-suggestion--highlight">' + doc.content.substring(start, end) + '</span>';
+        preview += doc.content.substring(end, previewEnd);
+        if (ellipsesAfter) {
+            preview += ' ...';
+        }
+        return this.getHit(doc, null, preview);
+
+    }
+    search(input) {
+        return new Promise((resolve, rej) => {
+            const results = this.getLunrResult(input);
+            const hits = [];
+            results.length > 5 && (results.length = 5);
+            this.titleHitsRes = []
+            this.contentHitsRes = []
+            results.forEach(result => {
+                const doc = this.searchDocs[result.ref];
+                const { metadata } = result.matchData;
+                for (let i in metadata) {
+                    if (metadata[i].title) {
+                        if (!this.titleHitsRes.includes(result.ref)) {
+                            const position = metadata[i].title.position[0]
+                            hits.push(this.getTitleHit(doc, position, input.length));
+                            this.titleHitsRes.push(result.ref);
+                        }
+                    } else if (metadata[i].content) {
+                        const position = metadata[i].content.position[0]
+                        hits.push(this.getContentHit(doc, position))
+                    } else if (metadata[i].keywords) {
+                        const position = metadata[i].keywords.position[0]
+                        hits.push(this.getKeywordHit(doc, position, input.length));
+                        this.titleHitsRes.push(result.ref);
+                    }
+                }
+            });
+            hits.length > 5 && (hits.length = 5);
+            resolve(hits);
+        });
+    }
+}
+
+export default LunrSearchAdapter;

+ 0 - 7
src/theme/SearchBar/styles.css

@@ -1,10 +1,3 @@
-/**
- * Copyright (c) 2017-present, Facebook, Inc.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */
-
 .search-icon {
   background-image: var(--ifm-navbar-search-input-icon);
   height: auto;

+ 112 - 0
src/theme/SearchBar/templates.js

@@ -0,0 +1,112 @@
+const prefix = 'algolia-docsearch';
+const suggestionPrefix = `${prefix}-suggestion`;
+const footerPrefix = `${prefix}-footer`;
+
+const templates = {
+  suggestion: `
+  <a class="${suggestionPrefix}
+    {{#isCategoryHeader}}${suggestionPrefix}__main{{/isCategoryHeader}}
+    {{#isSubCategoryHeader}}${suggestionPrefix}__secondary{{/isSubCategoryHeader}}
+    "
+    aria-label="Link to the result"
+    href="{{{url}}}"
+    >
+    <div class="${suggestionPrefix}--category-header">
+        <span class="${suggestionPrefix}--category-header-lvl0">{{{category}}}</span>
+    </div>
+    <div class="${suggestionPrefix}--wrapper">
+      <div class="${suggestionPrefix}--subcategory-column">
+        <span class="${suggestionPrefix}--subcategory-column-text">{{{subcategory}}}</span>
+      </div>
+      {{#isTextOrSubcategoryNonEmpty}}
+      <div class="${suggestionPrefix}--content">
+        <div class="${suggestionPrefix}--subcategory-inline">{{{subcategory}}}</div>
+        <div class="${suggestionPrefix}--title">{{{title}}}</div>
+        {{#text}}<div class="${suggestionPrefix}--text">{{{text}}}</div>{{/text}}
+      </div>
+      {{/isTextOrSubcategoryNonEmpty}}
+    </div>
+  </a>
+  `,
+  suggestionSimple: `
+  <div class="${suggestionPrefix}
+    {{#isCategoryHeader}}${suggestionPrefix}__main{{/isCategoryHeader}}
+    {{#isSubCategoryHeader}}${suggestionPrefix}__secondary{{/isSubCategoryHeader}}
+    suggestion-layout-simple
+  ">
+    <div class="${suggestionPrefix}--category-header">
+        {{^isLvl0}}
+        <span class="${suggestionPrefix}--category-header-lvl0 ${suggestionPrefix}--category-header-item">{{{category}}}</span>
+          {{^isLvl1}}
+          {{^isLvl1EmptyOrDuplicate}}
+          <span class="${suggestionPrefix}--category-header-lvl1 ${suggestionPrefix}--category-header-item">
+              {{{subcategory}}}
+          </span>
+          {{/isLvl1EmptyOrDuplicate}}
+          {{/isLvl1}}
+        {{/isLvl0}}
+        <div class="${suggestionPrefix}--title ${suggestionPrefix}--category-header-item">
+            {{#isLvl2}}
+                {{{title}}}
+            {{/isLvl2}}
+            {{#isLvl1}}
+                {{{subcategory}}}
+            {{/isLvl1}}
+            {{#isLvl0}}
+                {{{category}}}
+            {{/isLvl0}}
+        </div>
+    </div>
+    <div class="${suggestionPrefix}--wrapper">
+      {{#text}}
+      <div class="${suggestionPrefix}--content">
+        <div class="${suggestionPrefix}--text">{{{text}}}</div>
+      </div>
+      {{/text}}
+    </div>
+  </div>
+  `,
+  footer: `
+    <div class="${footerPrefix}">
+    </div>
+  `,
+  empty: `
+  <div class="${suggestionPrefix}">
+    <div class="${suggestionPrefix}--wrapper">
+        <div class="${suggestionPrefix}--content ${suggestionPrefix}--no-results">
+            <div class="${suggestionPrefix}--title">
+                <div class="${suggestionPrefix}--text">
+                    No results found for query <b>"{{query}}"</b>
+                </div>
+            </div>
+        </div>
+    </div>
+  </div>
+  `,
+  searchBox: `
+  <form novalidate="novalidate" onsubmit="return false;" class="searchbox">
+    <div role="search" class="searchbox__wrapper">
+      <input id="docsearch" type="search" name="search" placeholder="Search the docs" autocomplete="off" required="required" class="searchbox__input"/>
+      <button type="submit" title="Submit your search query." class="searchbox__submit" >
+        <svg width=12 height=12 role="img" aria-label="Search">
+          <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-search-13"></use>
+        </svg>
+      </button>
+      <button type="reset" title="Clear the search query." class="searchbox__reset hide">
+        <svg width=12 height=12 role="img" aria-label="Reset">
+          <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-clear-3"></use>
+        </svg>
+      </button>
+    </div>
+</form>
+
+<div class="svg-icons" style="height: 0; width: 0; position: absolute; visibility: hidden">
+  <svg xmlns="http://www.w3.org/2000/svg">
+    <symbol id="sbx-icon-clear-3" viewBox="0 0 40 40"><path d="M16.228 20L1.886 5.657 0 3.772 3.772 0l1.885 1.886L20 16.228 34.343 1.886 36.228 0 40 3.772l-1.886 1.885L23.772 20l14.342 14.343L40 36.228 36.228 40l-1.885-1.886L20 23.772 5.657 38.114 3.772 40 0 36.228l1.886-1.885L16.228 20z" fill-rule="evenodd"></symbol>
+    <symbol id="sbx-icon-search-13" viewBox="0 0 40 40"><path d="M26.806 29.012a16.312 16.312 0 0 1-10.427 3.746C7.332 32.758 0 25.425 0 16.378 0 7.334 7.333 0 16.38 0c9.045 0 16.378 7.333 16.378 16.38 0 3.96-1.406 7.593-3.746 10.426L39.547 37.34c.607.608.61 1.59-.004 2.203a1.56 1.56 0 0 1-2.202.004L26.807 29.012zm-10.427.627c7.322 0 13.26-5.938 13.26-13.26 0-7.324-5.938-13.26-13.26-13.26-7.324 0-13.26 5.936-13.26 13.26 0 7.322 5.936 13.26 13.26 13.26z" fill-rule="evenodd"></symbol>
+  </svg>
+</div>
+  `,
+};
+
+export default templates;

+ 270 - 0
src/theme/SearchBar/utils.js

@@ -0,0 +1,270 @@
+import $ from "autocomplete.js/zepto";
+
+const utils = {
+  /*
+  * Move the content of an object key one level higher.
+  * eg.
+  * {
+  *   name: 'My name',
+  *   hierarchy: {
+  *     lvl0: 'Foo',
+  *     lvl1: 'Bar'
+  *   }
+  * }
+  * Will be converted to
+  * {
+  *   name: 'My name',
+  *   lvl0: 'Foo',
+  *   lvl1: 'Bar'
+  * }
+  * @param {Object} object Main object
+  * @param {String} property Main object key to move up
+  * @return {Object}
+  * @throws Error when key is not an attribute of Object or is not an object itself
+  */
+  mergeKeyWithParent(object, property) {
+    if (object[property] === undefined) {
+      return object;
+    }
+    if (typeof object[property] !== 'object') {
+      return object;
+    }
+    const newObject = $.extend({}, object, object[property]);
+    delete newObject[property];
+    return newObject;
+  },
+  /*
+  * Group all objects of a collection by the value of the specified attribute
+  * If the attribute is a string, use the lowercase form.
+  *
+  * eg.
+  * groupBy([
+  *   {name: 'Tim', category: 'dev'},
+  *   {name: 'Vincent', category: 'dev'},
+  *   {name: 'Ben', category: 'sales'},
+  *   {name: 'Jeremy', category: 'sales'},
+  *   {name: 'AlexS', category: 'dev'},
+  *   {name: 'AlexK', category: 'sales'}
+  * ], 'category');
+  * =>
+  * {
+  *   'devs': [
+  *     {name: 'Tim', category: 'dev'},
+  *     {name: 'Vincent', category: 'dev'},
+  *     {name: 'AlexS', category: 'dev'}
+  *   ],
+  *   'sales': [
+  *     {name: 'Ben', category: 'sales'},
+  *     {name: 'Jeremy', category: 'sales'},
+  *     {name: 'AlexK', category: 'sales'}
+  *   ]
+  * }
+  * @param {array} collection Array of objects to group
+  * @param {String} property The attribute on which apply the grouping
+  * @return {array}
+  * @throws Error when one of the element does not have the specified property
+  */
+  groupBy(collection, property) {
+    const newCollection = {};
+    $.each(collection, (index, item) => {
+      if (item[property] === undefined) {
+        throw new Error(`[groupBy]: Object has no key ${property}`);
+      }
+      let key = item[property];
+      if (typeof key === 'string') {
+        key = key.toLowerCase();
+      }
+      // fix #171 the given data type of docsearch hits might be conflict with the properties of the native Object,
+      // such as the constructor, so we need to do this check.
+      if (!Object.prototype.hasOwnProperty.call(newCollection, key)) {
+        newCollection[key] = [];
+      }
+      newCollection[key].push(item);
+    });
+    return newCollection;
+  },
+  /*
+  * Return an array of all the values of the specified object
+  * eg.
+  * values({
+  *   foo: 42,
+  *   bar: true,
+  *   baz: 'yep'
+  * })
+  * =>
+  * [42, true, yep]
+  * @param {object} object Object to extract values from
+  * @return {array}
+  */
+  values(object) {
+    return Object.keys(object).map(key => object[key]);
+  },
+  /*
+  * Flattens an array
+  * eg.
+  * flatten([1, 2, [3, 4], [5, 6]])
+  * =>
+  * [1, 2, 3, 4, 5, 6]
+  * @param {array} array Array to flatten
+  * @return {array}
+  */
+  flatten(array) {
+    const results = [];
+    array.forEach(value => {
+      if (!Array.isArray(value)) {
+        results.push(value);
+        return;
+      }
+      value.forEach(subvalue => {
+        results.push(subvalue);
+      });
+    });
+    return results;
+  },
+  /*
+  * Flatten all values of an object into an array, marking each first element of
+  * each group with a specific flag
+  * eg.
+  * flattenAndFlagFirst({
+  *   'devs': [
+  *     {name: 'Tim', category: 'dev'},
+  *     {name: 'Vincent', category: 'dev'},
+  *     {name: 'AlexS', category: 'dev'}
+  *   ],
+  *   'sales': [
+  *     {name: 'Ben', category: 'sales'},
+  *     {name: 'Jeremy', category: 'sales'},
+  *     {name: 'AlexK', category: 'sales'}
+  *   ]
+  * , 'isTop');
+  * =>
+  * [
+  *     {name: 'Tim', category: 'dev', isTop: true},
+  *     {name: 'Vincent', category: 'dev', isTop: false},
+  *     {name: 'AlexS', category: 'dev', isTop: false},
+  *     {name: 'Ben', category: 'sales', isTop: true},
+  *     {name: 'Jeremy', category: 'sales', isTop: false},
+  *     {name: 'AlexK', category: 'sales', isTop: false}
+  * ]
+  * @param {object} object Object to flatten
+  * @param {string} flag Flag to set to true on first element of each group
+  * @return {array}
+  */
+  flattenAndFlagFirst(object, flag) {
+    const values = this.values(object).map(collection =>
+      collection.map((item, index) => {
+        // eslint-disable-next-line no-param-reassign
+        item[flag] = index === 0;
+        return item;
+      })
+    );
+    return this.flatten(values);
+  },
+  /*
+  * Removes all empty strings, null, false and undefined elements array
+  * eg.
+  * compact([42, false, null, undefined, '', [], 'foo']);
+  * =>
+  * [42, [], 'foo']
+  * @param {array} array Array to compact
+  * @return {array}
+  */
+  compact(array) {
+    const results = [];
+    array.forEach(value => {
+      if (!value) {
+        return;
+      }
+      results.push(value);
+    });
+    return results;
+  },
+  /*
+   * Returns the highlighted value of the specified key in the specified object.
+   * If no highlighted value is available, will return the key value directly
+   * eg.
+   * getHighlightedValue({
+   *    _highlightResult: {
+   *      text: {
+   *        value: '<mark>foo</mark>'
+   *      }
+   *    },
+   *    text: 'foo'
+   * }, 'text');
+   * =>
+   * '<mark>foo</mark>'
+   * @param {object} object Hit object returned by the Algolia API
+   * @param {string} property Object key to look for
+   * @return {string}
+   **/
+  getHighlightedValue(object, property) {
+    if (
+      object._highlightResult &&
+      object._highlightResult.hierarchy_camel &&
+      object._highlightResult.hierarchy_camel[property] &&
+      object._highlightResult.hierarchy_camel[property].matchLevel &&
+      object._highlightResult.hierarchy_camel[property].matchLevel !== 'none' &&
+      object._highlightResult.hierarchy_camel[property].value
+    ) {
+      return object._highlightResult.hierarchy_camel[property].value;
+    }
+    if (
+      object._highlightResult &&
+      object._highlightResult &&
+      object._highlightResult[property] &&
+      object._highlightResult[property].value
+    ) {
+      return object._highlightResult[property].value;
+    }
+    return object[property];
+  },
+  /*
+   * Returns the snippeted value of the specified key in the specified object.
+   * If no highlighted value is available, will return the key value directly.
+   * Will add starting and ending ellipsis (…) if we detect that a sentence is
+   * incomplete
+   * eg.
+   * getSnippetedValue({
+   *    _snippetResult: {
+   *      text: {
+   *        value: '<mark>This is an unfinished sentence</mark>'
+   *      }
+   *    },
+   *    text: 'This is an unfinished sentence'
+   * }, 'text');
+   * =>
+   * '<mark>This is an unfinished sentence</mark>…'
+   * @param {object} object Hit object returned by the Algolia API
+   * @param {string} property Object key to look for
+   * @return {string}
+   **/
+  getSnippetedValue(object, property) {
+    if (
+      !object._snippetResult ||
+      !object._snippetResult[property] ||
+      !object._snippetResult[property].value
+    ) {
+      return object[property];
+    }
+    let snippet = object._snippetResult[property].value;
+
+    if (snippet[0] !== snippet[0].toUpperCase()) {
+      snippet = `…${snippet}`;
+    }
+    if (['.', '!', '?'].indexOf(snippet[snippet.length - 1]) === -1) {
+      snippet = `${snippet}…`;
+    }
+    return snippet;
+  },
+  /*
+  * Deep clone an object.
+  * Note: This will not clone functions and dates
+  * @param {object} object Object to clone
+  * @return {object}
+  */
+  deepClone(object) {
+    return JSON.parse(JSON.stringify(object));
+  },
+};
+
+export default utils;

+ 1 - 1
yarn.lock

@@ -4017,7 +4017,7 @@
   dependencies:
     "@leichtgewicht/ip-codec" "^2.0.1"
 
-"docusaurus-lunr-search@^2.1.15":
+"docusaurus-lunr-search@^2.3.1":
   "integrity" "sha512-2F+M8lA6HLTg9c7oaZKbi5CcukFP7NUkIZhLOxUFHJf6yF6L10dfogInkdI2KIeFWebmZLz2JvAbHIIvw7rcEA=="
   "resolved" "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-2.3.1.tgz"
   "version" "2.3.1"