Просмотр исходного кода

feat(docs): Add search to docs

Michael Bromley 7 лет назад
Родитель
Сommit
cde48cbe85

+ 9 - 4
docs/assets/scripts/main.ts

@@ -1,13 +1,11 @@
-import '@webcomponents/custom-elements';
+// import '@webcomponents/custom-elements';
 
-import { initIcons } from './icons';
+import { SearchWidget } from './search-widget';
 import { TocHighlighter } from './toc-highlighter';
 
 // tslint:disable-next-line
 require('../styles/main.scss');
 
-initIcons();
-
 document.addEventListener('DOMContentLoaded', () => {
     const topBar = document.querySelector('.top-bar');
     if (topBar) {
@@ -19,9 +17,16 @@ document.addEventListener('DOMContentLoaded', () => {
             }
         });
     }
+
     const toc = document.querySelector('#TableOfContents');
     if (toc) {
         const tocHighlighter = new TocHighlighter(toc);
         tocHighlighter.highlight();
     }
+
+    const searchInput = document.querySelector('#searchInput') as HTMLInputElement;
+    const searchWidget = new SearchWidget(searchInput);
+    const searchButton = document.querySelector('button.search-icon') as HTMLButtonElement;
+    searchButton.addEventListener('click', () => searchWidget.toggleActive());
+
 }, false);

+ 153 - 0
docs/assets/scripts/search-widget.ts

@@ -0,0 +1,153 @@
+import fuzzy, { FilterResult } from 'fuzzy';
+
+interface IndexItem {
+    title: string;
+    headings: string[];
+    url: string;
+}
+
+interface DenormalizedItem {
+    title: string;
+    heading: string;
+    url: string;
+}
+
+const KeyCode = {
+    UP: 38,
+    DOWN: 40,
+    ENTER: 13,
+    ESCAPE: 27,
+};
+
+/**
+ * This class implements the auto-suggest search box for searching the docs.
+ */
+export class SearchWidget {
+
+    private readonly MAX_RESULTS = 8;
+    private searchIndex: Promise<DenormalizedItem[]> | undefined;
+    private results: Array<FilterResult<DenormalizedItem>> = [];
+    private selectedIndex = -1;
+    private autocompleteDiv: HTMLDivElement;
+    private wrapperDiv: HTMLDivElement;
+    private listElement: HTMLUListElement;
+
+    constructor(private inputElement: HTMLInputElement) {
+        this.attachAutocomplete();
+
+        inputElement.addEventListener('input', (e) => {
+            this.handleInput(e as KeyboardEvent);
+        });
+
+        inputElement.addEventListener('keydown', (e: KeyboardEvent) => {
+            switch (e.keyCode) {
+                case KeyCode.UP:
+                    this.selectedIndex = this.selectedIndex === 0 ? this.results.length - 1 : this.selectedIndex - 1;
+                    e.preventDefault();
+                    break;
+                case KeyCode.DOWN:
+                    this.selectedIndex = this.selectedIndex === (this.results.length - 1) ? 0 : this.selectedIndex + 1;
+                    e.preventDefault();
+                    break;
+                case KeyCode.ENTER:
+                    const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement;
+                    if (selected) {
+                        selected.click();
+                        return;
+                    }
+                    break;
+                case KeyCode.ESCAPE:
+                    this.results = [];
+                    this.inputElement.blur();
+                    break;
+            }
+            this.render();
+        });
+    }
+
+    toggleActive() {
+        this.wrapperDiv.classList.toggle('focus');
+        if (this.wrapperDiv.classList.contains('focus')) {
+            this.inputElement.focus();
+        }
+    }
+
+    private render() {
+        const listItems = this.results
+            .map((result, i) => {
+                const { title, heading, url } = result.original;
+                const anchor = heading !== title ? '#' + heading.toLowerCase().replace(/\s/g, '-') : '';
+                const inner = `<div class="title">${title}</div><div class="heading">${result.string}</div>`;
+                return `<li class="${i === this.selectedIndex ? 'selected' : ''}"><a href="${url + anchor}">${inner}</a></li>`;
+            });
+        this.listElement.innerHTML = listItems.join('\n');
+    }
+
+    private attachAutocomplete() {
+        this.autocompleteDiv = document.createElement('div');
+        this.autocompleteDiv.classList.add('autocomplete');
+        this.listElement = document.createElement('ul');
+        this.autocompleteDiv.appendChild(this.listElement);
+        this.wrapperDiv = document.createElement('div');
+        this.wrapperDiv.classList.add('autocomplete-wrapper');
+
+        const parent = this.inputElement.parentElement;
+        if (parent) {
+            parent.insertBefore(this.wrapperDiv, this.inputElement);
+            this.wrapperDiv.appendChild(this.inputElement);
+            this.wrapperDiv.appendChild(this.autocompleteDiv);
+        }
+    }
+
+    private async handleInput(e: KeyboardEvent) {
+        const term = (e.target as HTMLInputElement).value.trim();
+        this.results = term ? await this.getResults(term) : [];
+        this.selectedIndex = 0;
+        this.render();
+    }
+
+    private async getResults(term: string) {
+        const items = await this.getSearchIndex();
+        return fuzzy.filter(
+            term,
+            items,
+            {
+                pre: '<span class="hl">',
+                post: '</span>',
+                extract(input: DenormalizedItem): string {
+                    return input.heading;
+                },
+            },
+        ).slice(0, this.MAX_RESULTS);
+    }
+
+    private getSearchIndex(): Promise<DenormalizedItem[]> {
+        if (!this.searchIndex) {
+            // tslint:disable:no-eval
+            this.searchIndex = fetch('/searchindex/index.html')
+                .then(res => res.text())
+                .then(res => eval(res))
+                .then((items: IndexItem[]) => {
+                    const denormalized: DenormalizedItem[] = [];
+                    for (const { title, headings, url } of items) {
+                        denormalized.push({
+                            title,
+                            heading: title,
+                            url,
+                        });
+                        if (headings.length) {
+                            for (const heading of headings) {
+                                denormalized.push({
+                                    title,
+                                    heading,
+                                    url,
+                                });
+                            }
+                        }
+                    }
+                    return denormalized;
+                });
+        }
+        return this.searchIndex;
+    }
+}

+ 72 - 0
docs/assets/styles/_search-widget.scss

@@ -0,0 +1,72 @@
+@import "variables";
+
+.autocomplete-wrapper {
+    position: relative;
+    width: 200px;
+    @media all and (max-width: $sm-breakpoint){
+        width: 100%;
+        padding: 0 6px;
+        position: absolute;
+        left: 0;
+        top: 54px;
+        transform: translateY(-200px);
+        opacity: 0.5;
+        transition: opacity 0.3s, transform 0s;
+
+        .autocomplete {
+            display: none;
+        }
+        &.focus {
+            transform: translateY(0);
+            opacity: 1;
+            .autocomplete {
+                display: block;
+            }
+        }
+    }
+}
+
+.autocomplete {
+    position: absolute;
+    background-color: $gray-100;
+    border-radius: 4px;
+    top: 35px;
+    right: 0;
+    max-height: 80vh;
+    width: 500px;
+    overflow: hidden;
+
+    @media all and (max-width: $sm-breakpoint){
+        width: 100%;
+    }
+
+    ul {
+        padding: 0;
+        margin: 0;
+        list-style-type: none;
+    }
+    li {
+        padding: 12px;
+        background-color: transparent;
+        transition: background-color 0.2s;
+        &.selected {
+            background-color: $gray-300;
+        }
+        a {
+            display: flex;
+            align-items: baseline;
+        }
+        .title {
+            color: $gray-800;
+            width: 200px;
+            font-size: 12px;
+        }
+        .heading {
+            color: $gray-900;
+            .hl {
+                background-color: transparentize($brand-color, 0.85);
+                color: darken($brand-color, 40%);
+            }
+        }
+    }
+}

+ 42 - 0
docs/assets/styles/_top-bar.scss

@@ -11,6 +11,10 @@
     align-items: center;
     transition: background-color 0.7s;
 
+    @media all and (max-width: $sm-breakpoint){
+        padding: 12px;
+    }
+
     &.landing-page {
         background-color: transparent;
 
@@ -37,6 +41,9 @@
     }
 
     .right {
+        @media all and (max-width: $sm-breakpoint){
+            text-align: right;
+        }
         a {
             margin-left: 12px;
         }
@@ -48,4 +55,39 @@
             text-decoration: none;
         }
     }
+
+    .search-input {
+        display: flex;
+    }
+    #searchInput {
+        width: 100%;
+        border-radius: 3px;
+        border: 1px solid $gray-900;
+        padding: 6px 9px;
+        background-color: transparentize($gray-300, 0.2);
+        color: $gray-900;
+        transition: background-color 0.2s;
+        margin: 0;
+
+        &:focus {
+            background-color: $gray-300;
+        }
+        @media all and (max-width: $sm-breakpoint){
+            background-color: $gray-300;
+        }
+    }
+    button.search-icon {
+        background-color: transparent;
+        padding: 0;
+        margin: 0;
+        border: none;
+        display: none;
+        img {
+            width: 24px;
+            height: 24px;
+        }
+        @media all and (max-width: $sm-breakpoint){
+            display: block;
+        }
+    }
 }

+ 1 - 1
docs/assets/styles/_variables.scss

@@ -35,7 +35,7 @@ $color-code-text: darken($brand-color, 45%);
 $body-background: white;
 $body-font-color: $gray-800;
 $body-font-weight: normal;
-$body-min-width: 25rem;
+$body-min-width: 15rem;
 
 $top-bar-height: 50px;
 

+ 1 - 0
docs/assets/styles/main.scss

@@ -7,6 +7,7 @@
 @import "menu";
 @import "shortcodes";
 @import "syntax";
+@import "search-widget";
 
 html {
     font-size: $font-size-base;

+ 11 - 0
docs/content/searchindex/_index.md

@@ -0,0 +1,11 @@
+---
+title: "Search Index"
+layout: "searchindex"
+---
+
+# search index
+
+The content of this file is not actually used - it is just a placeholder so that Hugo will generate a file named `searchindex/`.
+
+The contents of this file are actually generated by the [searchindex.html](../../layouts/searchindex/searchindex.html) template, which loops over all the content and extracts the title, heading and url data into a JSON data structure which can be loaded async by the [SearchWidget class](../../assets/scripts/search-widget.ts).
+

+ 8 - 0
docs/layouts/partials/top-bar.html

@@ -6,6 +6,14 @@
         </a>
     </div>
     <div class="flex-spacer"></div>
+    {{ if not .isLandingPage }}
+    <div class="search-input">
+        <button class="search-icon">
+            <img src="{{ "svg/clr-icon-search-light.svg" | absURL }}">
+        </button>
+        <input id="searchInput" placeholder="Search docs">
+    </div>
+    {{ end }}
     <div class="right">
         <a href="/docs">Docs</a>
         <a href="https://github.com/vendure-ecommerce/vendure">GitHub</a>

+ 11 - 0
docs/layouts/searchindex/searchindex.html

@@ -0,0 +1,11 @@
+{
+result :[
+    {{ range .Site.Pages -}}
+        {{ $titles := findRE "<h(2|3).*?>(.|\n)*?</h(2|3)>" .Content -}}
+        {{ $plain := apply $titles "plainify" "." -}}
+        {{ $cleaned := $plain | complement (slice "constructor" "Signature" "Members") -}}
+        {{ $quoted := apply $cleaned "replaceRE" ".*" "'$0'" "." }}
+        { title: '{{ .Title }}', headings: [{{ delimit $quoted ", " }}], url: '{{ .RelPermalink }}' },
+    {{- end }}
+]
+}

+ 1 - 0
docs/package.json

@@ -11,6 +11,7 @@
     "@types/webpack": "^4.4.24",
     "@webcomponents/custom-elements": "1.0.0",
     "css-loader": "^2.1.0",
+    "fuzzy": "^0.1.3",
     "mini-css-extract-plugin": "^0.5.0",
     "node-sass": "^4.11.0",
     "sass-loader": "^7.1.0",

+ 1 - 0
docs/static/svg/clr-icon-search-light.svg

@@ -0,0 +1 @@
+<svg version="1.1" viewBox="0 0 36 36" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" focusable="false" aria-hidden="true" role="img" width="32" height="32" fill="#ccc"><path class="clr-i-outline clr-i-outline-path-1" d="M16.33,5.05A10.95,10.95,0,1,1,5.39,16,11,11,0,0,1,16.33,5.05m0-2.05a13,13,0,1,0,13,13,13,13,0,0,0-13-13Z"/><path class="clr-i-outline clr-i-outline-path-2" d="M35,33.29l-7.37-7.42-1.42,1.41,7.37,7.42A1,1,0,1,0,35,33.29Z"/></svg>

+ 2 - 1
docs/tsconfig.json

@@ -6,6 +6,7 @@
       "es2017",
       "dom"
     ],
-    "target": "es5"
+    "target": "es5",
+    "sourceMap": true
   }
 }

+ 1 - 0
docs/webpack.config.ts

@@ -37,6 +37,7 @@ const config: webpack.Configuration = {
             chunkFilename: '[id].css',
         }),
     ],
+    devtool: 'inline-source-map',
 };
 
 export default config;

+ 5 - 0
docs/yarn.lock

@@ -1351,6 +1351,11 @@ fstream@^1.0.0, fstream@^1.0.2:
     mkdirp ">=0.5 0"
     rimraf "2"
 
+fuzzy@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/fuzzy/-/fuzzy-0.1.3.tgz#4c76ec2ff0ac1a36a9dccf9a00df8623078d4ed8"
+  integrity sha1-THbsL/CsGjap3M+aAN+GIweNTtg=
+
 gauge@~2.7.3:
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"