From 9f1d8cee8f2d63f19d4dd8ca43f5c7606b852cb9 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 30 Sep 2020 00:17:18 +0200 Subject: [PATCH] Add search to docs Closes: #295 --- project/ZolaPlugin.scala | 3 +- website/elm.json | 2 +- website/elm/Search.elm | 162 ++++++++++++++++++++++++ website/run-elm.sh | 2 +- website/site/sass/styles.sass | 7 + website/site/static/icons/search-20.svg | 5 + website/site/static/js/searchhelper.js | 130 +++++++++++++++++++ website/site/templates/404.html | 32 ++--- website/site/templates/fathom.html | 15 +++ website/site/templates/index.html | 16 +-- website/site/templates/overview.html | 38 +++--- website/site/templates/page.html | 37 +++--- website/site/templates/pages.html | 37 +++--- website/site/templates/search-head.html | 4 + website/site/templates/search-part.html | 16 +++ website/site/templates/section.html | 39 +++--- 16 files changed, 422 insertions(+), 123 deletions(-) create mode 100644 website/elm/Search.elm create mode 100644 website/site/static/icons/search-20.svg create mode 100644 website/site/static/js/searchhelper.js create mode 100644 website/site/templates/fathom.html create mode 100644 website/site/templates/search-head.html create mode 100644 website/site/templates/search-part.html diff --git a/project/ZolaPlugin.scala b/project/ZolaPlugin.scala index 2f433cbe..b2a559b5 100644 --- a/project/ZolaPlugin.scala +++ b/project/ZolaPlugin.scala @@ -99,7 +99,8 @@ object ZolaPlugin extends AutoPlugin { "--output", (zolaRoot / "static" / "js" / "bundle.js").absolutePath.toString, "--optimize", - (inDir / "elm" / "Main.elm").toString + (inDir / "elm" / "Main.elm").toString, + (inDir / "elm" / "Search.elm").toString ), inDir, logger diff --git a/website/elm.json b/website/elm.json index daaa0710..06d13ac8 100644 --- a/website/elm.json +++ b/website/elm.json @@ -9,12 +9,12 @@ "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/html": "1.0.0", + "elm/json": "1.1.3", "elm/random": "1.0.0", "elm-community/random-extra": "3.1.0", "elm-explorations/markdown": "1.0.0" }, "indirect": { - "elm/json": "1.1.3", "elm/time": "1.0.0", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.2", diff --git a/website/elm/Search.elm b/website/elm/Search.elm new file mode 100644 index 00000000..e62c732c --- /dev/null +++ b/website/elm/Search.elm @@ -0,0 +1,162 @@ +port module Search exposing (..) + +import Browser exposing (Document) +import Browser.Navigation exposing (Key) +import Html as H exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput, onSubmit) +import Json.Decode as D +import Markdown + + + +-- MAIN + + +main : Program Flags Model Msg +main = + Browser.element + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + + + +--- Model + + +type alias Flags = + {} + + +type alias Doc = + { body : String + , title : String + , id : String + } + + +type alias SearchEntry = + { ref : String + , score : Float + , doc : Doc + } + + +type alias Model = + { searchInput : String + , results : List SearchEntry + } + + +type Msg + = SetSearch String + | SubmitSearch + | GetSearchResults (List SearchEntry) + + + +--- Init + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( { searchInput = "" + , results = [] + } + , Cmd.none + ) + + + +--- Update + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + SetSearch str -> + ( { model | searchInput = str } + , Cmd.none + ) + + SubmitSearch -> + ( model, doSearch model.searchInput ) + + GetSearchResults list -> + ( { model | results = List.take 8 list }, Cmd.none ) + + +subscriptions : Model -> Sub Msg +subscriptions _ = + receiveSearch GetSearchResults + + + +--- View + + +view : Model -> Html Msg +view model = + H.form + [ class "form" + , onSubmit SubmitSearch + ] + [ div [ class "dropdown field is-active is-fullwidth" ] + [ div [ class "control has-icons-right is-fullwidth" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Search docs…" + , onInput SetSearch + , value model.searchInput + ] + [] + , span [ class "icon is-right is-small" ] + [ img [ src "/icons/search-20.svg" ] [] + ] + ] + , viewResults model.results + ] + ] + + +viewResults : List SearchEntry -> Html Msg +viewResults entries = + div + [ classList + [ ( "dropdown-menu", True ) + , ( "is-hidden", entries == [] ) + ] + ] + [ div [ class "dropdown-content" ] + (List.intersperse + (div [ class "dropdown-divider" ] []) + (List.map viewResult entries) + ) + ] + + +viewResult : SearchEntry -> Html Msg +viewResult result = + div [ class "dropdown-item" ] + [ a + [ class "is-size-5" + , href result.ref + ] + [ text result.doc.title + ] + , Markdown.toHtml [ class "content" ] result.doc.body + ] + + + +--- Ports + + +port receiveSearch : (List SearchEntry -> msg) -> Sub msg + + +port doSearch : String -> Cmd msg diff --git a/website/run-elm.sh b/website/run-elm.sh index 785bc548..e8b3770e 100755 --- a/website/run-elm.sh +++ b/website/run-elm.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -CMD="elm make --output site/static/js/bundle.js --optimize elm/Main.elm" +CMD="elm make --output site/static/js/bundle.js --optimize elm/Main.elm elm/Search.elm" $CMD inotifywait -m -e close_write -r elm/ | diff --git a/website/site/sass/styles.sass b/website/site/sass/styles.sass index a6744afd..2149c216 100644 --- a/website/site/sass/styles.sass +++ b/website/site/sass/styles.sass @@ -111,3 +111,10 @@ p.has-text margin-left: auto margin-right: auto max-width: $tablet + +.dropdown.is-fullwidth + width: 100% + +.dropdown + .control.is-fullwidth + width: 100% diff --git a/website/site/static/icons/search-20.svg b/website/site/static/icons/search-20.svg new file mode 100644 index 00000000..072fceb9 --- /dev/null +++ b/website/site/static/icons/search-20.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/site/static/js/searchhelper.js b/website/site/static/js/searchhelper.js new file mode 100644 index 00000000..ac26ba21 --- /dev/null +++ b/website/site/static/js/searchhelper.js @@ -0,0 +1,130 @@ +// Taken from mdbook +// The strategy is as follows: +// First, assign a value to each word in the document: +// Words that correspond to search terms (stemmer aware): 40 +// Normal words: 2 +// First word in a sentence: 8 +// Then use a sliding window with a constant number of words and count the +// sum of the values of the words within the window. Then use the window that got the +// maximum sum. If there are multiple maximas, then get the last one. +// Enclose the terms in *. +function makeTeaser(body, terms) { + var TERM_WEIGHT = 40; + var NORMAL_WORD_WEIGHT = 2; + var FIRST_WORD_WEIGHT = 8; + var TEASER_MAX_WORDS = 30; + + var stemmedTerms = terms.map(function (w) { + return elasticlunr.stemmer(w.toLowerCase()); + }); + var termFound = false; + var index = 0; + var weighted = []; // contains elements of ["word", weight, index_in_document] + + // split in sentences, then words + var sentences = body.toLowerCase().split(". "); + + for (var i in sentences) { + var words = sentences[i].split(" "); + var value = FIRST_WORD_WEIGHT; + + for (var j in words) { + var word = words[j]; + + if (word.length > 0) { + for (var k in stemmedTerms) { + if (elasticlunr.stemmer(word).startsWith(stemmedTerms[k])) { + value = TERM_WEIGHT; + termFound = true; + } + } + weighted.push([word, value, index]); + value = NORMAL_WORD_WEIGHT; + } + + index += word.length; + index += 1; // ' ' or '.' if last word in sentence + } + + index += 1; // because we split at a two-char boundary '. ' + } + + if (weighted.length === 0) { + return body; + } + + var windowWeights = []; + var windowSize = Math.min(weighted.length, TEASER_MAX_WORDS); + // We add a window with all the weights first + var curSum = 0; + for (var i = 0; i < windowSize; i++) { + curSum += weighted[i][1]; + } + windowWeights.push(curSum); + + for (var i = 0; i < weighted.length - windowSize; i++) { + curSum -= weighted[i][1]; + curSum += weighted[i + windowSize][1]; + windowWeights.push(curSum); + } + + // If we didn't find the term, just pick the first window + var maxSumIndex = 0; + if (termFound) { + var maxFound = 0; + // backwards + for (var i = windowWeights.length - 1; i >= 0; i--) { + if (windowWeights[i] > maxFound) { + maxFound = windowWeights[i]; + maxSumIndex = i; + } + } + } + + var teaser = []; + var startIndex = weighted[maxSumIndex][2]; + for (var i = maxSumIndex; i < maxSumIndex + windowSize; i++) { + var word = weighted[i]; + if (startIndex < word[2]) { + // missing text from index to start of `word` + teaser.push(body.substring(startIndex, word[2])); + startIndex = word[2]; + } + + // add around search terms + if (word[1] === TERM_WEIGHT) { + teaser.push("**"); + } + startIndex = word[2] + word[0].length; + teaser.push(body.substring(word[2], startIndex)); + + if (word[1] === TERM_WEIGHT) { + teaser.push("**"); + } + } + teaser.push("…"); + return teaser.join(""); +} + +var index = elasticlunr.Index.load(window.searchIndex); + + +var initElmSearch = function(elmSearch) { + var options = { + bool: "AND", + fields: { + title: {boost: 2}, + body: {boost: 1}, + } + }; + + elmSearch.ports.doSearch.subscribe(function(str) { + var results = index.search(str, options); + for (var i = 0; i < results.length; i ++) { + var teaser = makeTeaser(results[i].doc.body, str.split(" ")); + results[i].doc.body = teaser; + } + elmSearch.ports.receiveSearch.send(results); + }); + +}; diff --git a/website/site/templates/404.html b/website/site/templates/404.html index b9e452cd..6ec65b93 100644 --- a/website/site/templates/404.html +++ b/website/site/templates/404.html @@ -6,6 +6,7 @@ Not Found + {% include "search-head.html" %}
@@ -13,9 +14,16 @@ {% include "navbar.html" %}
-

- Not Found -

+
+
+

+ Not Found +

+
+
+ +
+
@@ -38,20 +46,6 @@ {% include "footer.html" %} - - - - + {% include "search-part.html" %} + {% include "fathom.html" %} diff --git a/website/site/templates/fathom.html b/website/site/templates/fathom.html new file mode 100644 index 00000000..108cc7ac --- /dev/null +++ b/website/site/templates/fathom.html @@ -0,0 +1,15 @@ + + + diff --git a/website/site/templates/index.html b/website/site/templates/index.html index 27bcaada..e29900eb 100644 --- a/website/site/templates/index.html +++ b/website/site/templates/index.html @@ -17,19 +17,5 @@ flags: elmFlags }); - - - + {% include "fathom.html" %} diff --git a/website/site/templates/overview.html b/website/site/templates/overview.html index 16a0bede..28f327f0 100644 --- a/website/site/templates/overview.html +++ b/website/site/templates/overview.html @@ -4,6 +4,7 @@ {% include "meta.html" %} {{ section.title }} – Docspell Documentation + {% include "search-head.html" %}
@@ -11,12 +12,19 @@ {% include "navbar.html" %}
-

- Docspell Documentation -

-

- {{ section.title }} -

+
+
+

+ Docspell Documentation +

+

+ {{ section.title }} +

+
+
+ +
+
@@ -56,19 +64,7 @@ {% include "footer.html" %} - - - + {% include "search-part.html" %} + {% include "fathom.html" %} + diff --git a/website/site/templates/page.html b/website/site/templates/page.html index da979217..376bccc2 100644 --- a/website/site/templates/page.html +++ b/website/site/templates/page.html @@ -4,6 +4,7 @@ {% include "meta.html" %} {{ page.title }} – Docspell Documentation + {% include "search-head.html" %}
@@ -11,12 +12,19 @@ {% include "navbar.html" %}
-

- {{ page.title }} -

-

- Docspell Documentation -

+
+
+

+ {{ page.title }} +

+

+ Docspell Documentation +

+
+
+ +
+