From b1d3b967b78ee77db6bd3b38b7ccf49e8de10298 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Mon, 2 Oct 2017 10:12:59 +0200 Subject: [PATCH 01/14] Fix with .gitattributes: Git on Linux tried to autoconvert line endings for fonts and png. --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 45bca848f8..81c7df5e5e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,7 @@ * text=auto eol=lf *.rs rust +*.woff -text +*.ttf -text +*.otf -text +*.png -text From 893dc39b60044ee6d766c9fe8f351ae73792aeee Mon Sep 17 00:00:00 2001 From: Phaiax Date: Mon, 2 Oct 2017 14:32:27 +0200 Subject: [PATCH 02/14] Search: Add UI, elasticlunr.js, demo by indexing current page --- src/theme/book.css | 34 +++++++--- src/theme/book.js | 128 ++++++++++++++++++++++++++++++++++- src/theme/elasticlunr.min.js | 10 +++ src/theme/index.hbs | 17 ++++- 4 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 src/theme/elasticlunr.min.js diff --git a/src/theme/book.css b/src/theme/book.css index f0967be17a..7a2a29225d 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -151,11 +151,12 @@ table thead td { max-width: 100%; } .menu-bar { - position: relative; height: 50px; + display: flex; + justify-content: space-between; + align-items: baseline; } .menu-bar i { - position: relative; margin: 0 10px; z-index: 10; line-height: 50px; @@ -174,16 +175,29 @@ table thead td { .menu-bar .right-buttons { float: right; } +#searchbar { + border: 1px solid #BBB; + border-radius: 3px; + padding: 3px 5px; + width: 50px; + transition: width 0.5s ease-in-out; +} +#searchbar:focus, #searchbar:hover, #searchbar.active { + width: 150px; +} +.searchresults-header { + font-weight: bold; + font-size: 1em; +} +.searchresults-outer { + border-bottom: 1px dashed #CCC; + display: none; +} .menu-title { - display: inline-block; + display: inline; font-weight: 200; font-size: 20px; line-height: 50px; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; text-align: center; margin: 0; opacity: 0; @@ -924,7 +938,9 @@ table thead td { #sidebar, #menu-bar, .nav-chapters, - .mobile-nav-chapters { + .mobile-nav-chapters, + #searchbar, + #search-go { display: none; } #page-wrapper { diff --git a/src/theme/book.js b/src/theme/book.js index 855e5b9185..28c3987905 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -38,11 +38,13 @@ $( document ).ready(function() { var KEY_CODES = { PREVIOUS_KEY: 37, - NEXT_KEY: 39 + NEXT_KEY: 39, + SEARCH_KEY: 83 }; $(document).on('keydown', function (e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if ($('#searchbar').is( ":focus" )) { return; } switch (e.keyCode) { case KEY_CODES.NEXT_KEY: e.preventDefault(); @@ -56,6 +58,10 @@ $( document ).ready(function() { window.location.href = $('.nav-chapters.previous').attr('href'); } break; + case KEY_CODES.SEARCH_KEY: + e.preventDefault(); + $('#searchbar').focus(); + break; } }); @@ -82,6 +88,81 @@ $( document ).ready(function() { sidebar.scrollTop(activeSection.offset().top); } + // For testing purposes: Index current page + var searchindex = create_text_searchindex(); + var current_searchterm = ""; + var teaser_size_half = 80; + + // Searchbar + $("#searchbar").on('keyup', function (e) { + var display = $('#searchresults'); + var outer = $("#searchresults-outer"); + + var searchterm = e.target.value.trim(); + if (searchterm != "") { + // keep searchbar expanded + $(e.target).addClass("active"); + + // Don't search twice the same + if (current_searchterm == searchterm) { return; } + else { current_searchterm = searchterm; } + + // Do the actual search + var results = searchindex.search(searchterm, { + bool: "AND", + expand: true + }); + + // Display search metrics + var searchheader = ""; + if (results.length > 0) { + searchheader = results.length + " search results for '" + searchterm + "':"; + } else if (results.length == 1) { + searchheader = results.length + " search result for '" + searchterm + "':"; + } else { + searchheader = "No search results for '" + searchterm + "'."; + } + $('#searchresults-header').text(searchheader); + + // Clear and insert results + var firstterm = searchterm.split(' ')[0]; + display.empty(); + for(var i = 0, size = results.length; i < size ; i++){ + var result = results[i]; + document.lsd = result.doc; + var firstoccurence = result.doc.body.search(firstterm); + var teaser = ""; + if (firstoccurence != -1) { + var teaserstartindex = firstoccurence - teaser_size_half; + var nextwordindex = result.doc.body.indexOf(" ", teaserstartindex); + if (nextwordindex != -1) { + teaserstartindex = nextwordindex; + } + var teaserendindex = firstoccurence + teaser_size_half; + nextwordindex = result.doc.body.indexOf(" ", teaserendindex); + if (nextwordindex != -1) { + teaserendindex = nextwordindex; + } + teaser = (teaserstartindex > 0) ? "..." : ""; + teaser += result.doc.body.substring(teaserstartindex, teaserendindex) + "..."; + } else { + teaser = result.doc.body.substr(0, 80) + "..."; + } + + display.append('
  • ' + result.doc.title + ': ' + + teaser + "
  • "); + } + + // Display and scroll to results + sidebar.scrollTop(0); + outer.slideDown(); + } else { + // searchbar can shrink + $(e.target).removeClass("active"); + outer.slideUp(); + display.empty(); + } + }); // Theme button $("#theme-toggle").click(function(){ @@ -361,7 +442,7 @@ function run_rust_code(code_block) { } let text = playpen_text(code_block); - + var params = { channel: "stable", mode: "debug", @@ -392,3 +473,46 @@ function run_rust_code(code_block) { }, }); } + +function create_text_searchindex() { + var searchindex = elasticlunr(function () { + this.addField('body'); + this.addField('title'); + this.setRef('id'); + }); + var content = $("#content"); + var paragraphs = content.children(); + var curr_title = ""; + var curr_body = ""; + var curr_ref = ""; + var push = function(ref) { + if ((curr_title.length > 0 || curr_body.length > 0) && curr_ref.length > 0) { + var doc = { + "id": curr_ref, + "body": curr_body, + "title": curr_title + } + searchindex.addDoc(doc); + } + curr_body = ""; + curr_title = ""; + curr_ref = ""; + }; + paragraphs.each(function(index, element) { + // todo uppercase + var el = $(element); + if (el.prop('nodeName').toUpperCase() == "A") { + // new header, push old paragraph to index + push(index); + curr_title = el.text(); + curr_ref = el.attr('href'); + } else { + curr_body += " \n " + el.text(); + } + // last paragraph + if (index == paragraphs.length - 1) { + push(index); + } + }); + return searchindex; +} \ No newline at end of file diff --git a/src/theme/elasticlunr.min.js b/src/theme/elasticlunr.min.js new file mode 100644 index 0000000000..94b20dd2ef --- /dev/null +++ b/src/theme/elasticlunr.min.js @@ -0,0 +1,10 @@ +/** + * elasticlunr - http://weixsong.github.io + * Lightweight full-text search engine in Javascript for browser search and offline search. - 0.9.5 + * + * Copyright (C) 2017 Oliver Nightingale + * Copyright (C) 2017 Wei Song + * MIT Licensed + * @license + */ +!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();o + + + + @@ -68,6 +76,11 @@ @@ -78,6 +91,8 @@
    + +

    {{ book_title }}

    @@ -138,7 +153,7 @@ + + + + + + @@ -76,11 +86,6 @@ @@ -91,8 +96,7 @@
    - - +

    {{ book_title }}

    @@ -104,6 +108,17 @@ +
    + + +
    +
    +
      +
    +
    +
    + +
    {{{ content }}}
    diff --git a/src/theme/jquery.mark.min.js b/src/theme/jquery.mark.min.js new file mode 100644 index 0000000000..b8710fe901 --- /dev/null +++ b/src/theme/jquery.mark.min.js @@ -0,0 +1,7 @@ +/*!*************************************************** + * mark.js v8.11.0 + * https://github.com/julmot/mark.js + * Copyright (c) 2014–2017, Julian Motz + * Released under the MIT license https://git.io/vwTVl + *****************************************************/ +"use strict";function _classCallCheck(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}var _extends=Object.assign||function(e){for(var t=1;t-1||r.indexOf("Trident")>-1)&&(this.ie=!0)}return _createClass(n,[{key:"log",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"debug",n=this.opt.log;this.opt.debug&&"object"===(void 0===n?"undefined":_typeof(n))&&"function"==typeof n[t]&&n[t]("mark.js: "+e)}},{key:"escapeStr",value:function(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}},{key:"createRegExp",value:function(e){return"disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e)}},{key:"createSynonymsRegExp",value:function(e){var t=this.opt.synonyms,n=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(var i in t)if(t.hasOwnProperty(i)){var o=t[i],a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),s="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==a&&""!==s&&(e=e.replace(new RegExp("("+a+"|"+s+")","gm"+n),r+"("+this.processSynomyms(a)+"|"+this.processSynomyms(s)+")"+r))}return e}},{key:"processSynomyms",value:function(e){return(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}},{key:"setupWildcardsRegExp",value:function(e){return(e=e.replace(/(?:\\)*\?/g,function(e){return"\\"===e.charAt(0)?"?":""})).replace(/(?:\\)*\*/g,function(e){return"\\"===e.charAt(0)?"*":""})}},{key:"createWildcardsRegExp",value:function(e){var t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}},{key:"setupIgnoreJoinersRegExp",value:function(e){return e.replace(/[^(|)\\]/g,function(e,t,n){var r=n.charAt(t+1);return/[(|)\\]/.test(r)||""===r?e:e+"\0"})}},{key:"createJoinersRegExp",value:function(e){var t=[],n=this.opt.ignorePunctuation;return Array.isArray(n)&&n.length&&t.push(this.escapeStr(n.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join("["+t.join("")+"]*"):e}},{key:"createDiacriticsRegExp",value:function(e){var t=this.opt.caseSensitive?"":"i",n=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"],r=[];return e.split("").forEach(function(i){n.every(function(n){if(-1!==n.indexOf(i)){if(r.indexOf(n)>-1)return!1;e=e.replace(new RegExp("["+n+"]","gm"+t),"["+n+"]"),r.push(n)}return!0})}),e}},{key:"createMergedBlanksRegExp",value:function(e){return e.replace(/[\s]+/gim,"[\\s]+")}},{key:"createAccuracyRegExp",value:function(e){var t=this,n=this.opt.accuracy,r="string"==typeof n?n:n.value,i="";switch(("string"==typeof n?[]:n.limiters).forEach(function(e){i+="|"+t.escapeStr(e)}),r){case"partially":default:return"()("+e+")";case"complementary":return"()([^"+(i="\\s"+(i||this.escapeStr("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿")))+"]*"+e+"[^"+i+"]*)";case"exactly":return"(^|\\s"+i+")("+e+")(?=$|\\s"+i+")"}}},{key:"getSeparatedKeywords",value:function(e){var t=this,n=[];return e.forEach(function(e){t.opt.separateWordSearch?e.split(" ").forEach(function(e){e.trim()&&-1===n.indexOf(e)&&n.push(e)}):e.trim()&&-1===n.indexOf(e)&&n.push(e)}),{keywords:n.sort(function(e,t){return t.length-e.length}),length:n.length}}},{key:"isNumeric",value:function(e){return Number(parseFloat(e))==e}},{key:"checkRanges",value:function(e){var t=this;if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];var n=[],r=0;return e.sort(function(e,t){return e.start-t.start}).forEach(function(e){var i=t.callNoMatchOnInvalidRanges(e,r),o=i.start,a=i.end;i.valid&&(e.start=o,e.length=a-o,n.push(e),r=a)}),n}},{key:"callNoMatchOnInvalidRanges",value:function(e,t){var n=void 0,r=void 0,i=!1;return e&&void 0!==e.start?(r=(n=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-n>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+JSON.stringify(e)),this.opt.noMatch(e))):(this.log("Ignoring invalid range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:n,end:r,valid:i}}},{key:"checkWhitespaceRanges",value:function(e,t,n){var r=void 0,i=!0,o=n.length,a=t-o,s=parseInt(e.start,10)-a;return s=s>o?o:s,(r=s+parseInt(e.length,10))>o&&(r=o,this.log("End range automatically set to the max value of "+o)),s<0||r-s<0||s>o||r>o?(i=!1,this.log("Invalid range: "+JSON.stringify(e)),this.opt.noMatch(e)):""===n.substring(s,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:s,end:r,valid:i}}},{key:"getTextNodes",value:function(e){var t=this,n="",r=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,function(e){r.push({start:n.length,end:(n+=e.textContent).length,node:e})},function(e){return t.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},function(){e({value:n,nodes:r})})}},{key:"matchesExclude",value:function(e){return i.matches(e,this.opt.exclude.concat(["script","style","title","head","html"]))}},{key:"wrapRangeInTextNode",value:function(e,n,r){var i=this.opt.element?this.opt.element:"mark",o=e.splitText(n),a=o.splitText(r-n),s=t.createElement(i);return s.setAttribute("data-markjs","true"),this.opt.className&&s.setAttribute("class",this.opt.className),s.textContent=o.textContent,o.parentNode.replaceChild(s,o),a}},{key:"wrapRangeInMappedTextNode",value:function(e,t,n,r,i){var o=this;e.nodes.every(function(a,s){var c=e.nodes[s+1];if(void 0===c||c.start>t){if(!r(a.node))return!1;var u=t-a.start,l=(n>a.end?a.end:n)-a.start,h=e.value.substr(0,a.start),f=e.value.substr(l+a.start);if(a.node=o.wrapRangeInTextNode(a.node,u,l),e.value=h+f,e.nodes.forEach(function(t,n){n>=s&&(e.nodes[n].start>0&&n!==s&&(e.nodes[n].start-=l),e.nodes[n].end-=l)}),n-=l,i(a.node.previousSibling,a.start),!(n>a.end))return!1;t=a.end}return!0})}},{key:"wrapMatches",value:function(e,t,n,r,i){var o=this,a=0===t?0:t+1;this.getTextNodes(function(t){t.nodes.forEach(function(t){t=t.node;for(var i=void 0;null!==(i=e.exec(t.textContent))&&""!==i[a];)if(n(i[a],t)){var s=i.index;if(0!==a)for(var c=1;c1&&void 0!==arguments[1])||arguments[1],r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:5e3;_classCallCheck(this,e),this.ctx=t,this.iframes=n,this.exclude=r,this.iframesTimeout=i}return _createClass(e,[{key:"getContexts",value:function(){var e=[];return(void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(t.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(function(t){var n=e.filter(function(e){return e.contains(t)}).length>0;-1!==e.indexOf(t)||n||e.push(t)}),e}},{key:"getIframeContents",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){},r=void 0;try{var i=e.contentWindow;if(r=i.document,!i||!r)throw new Error("iframe inaccessible")}catch(e){n()}r&&t(r)}},{key:"isIframeBlank",value:function(e){var t="about:blank",n=e.getAttribute("src").trim();return e.contentWindow.location.href===t&&n!==t&&n}},{key:"observeIframeLoad",value:function(e,t,n){var r=this,i=!1,o=null,a=function a(){if(!i){i=!0,clearTimeout(o);try{r.isIframeBlank(e)||(e.removeEventListener("load",a),r.getIframeContents(e,t,n))}catch(e){n()}}};e.addEventListener("load",a),o=setTimeout(a,this.iframesTimeout)}},{key:"onIframeReady",value:function(e,t,n){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,n):this.getIframeContents(e,t,n):this.observeIframeLoad(e,t,n)}catch(e){n()}}},{key:"waitForIframes",value:function(e,t){var n=this,r=0;this.forEachIframe(e,function(){return!0},function(e){r++,n.waitForIframes(e.querySelector("html"),function(){--r||t()})},function(e){e||t()})}},{key:"forEachIframe",value:function(t,n,r){var i=this,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:function(){},a=t.querySelectorAll("iframe"),s=a.length,c=0;a=Array.prototype.slice.call(a);var u=function(){--s<=0&&o(c)};s||u(),a.forEach(function(t){e.matches(t,i.exclude)?u():i.onIframeReady(t,function(e){n(t)&&(c++,r(e)),u()},u)})}},{key:"createIterator",value:function(e,n,r){return t.createNodeIterator(e,n,r,!1)}},{key:"createInstanceOnIframe",value:function(t){return new e(t.querySelector("html"),this.iframes)}},{key:"compareNodeIframe",value:function(e,t,n){if(e.compareDocumentPosition(n)&Node.DOCUMENT_POSITION_PRECEDING){if(null===t)return!0;if(t.compareDocumentPosition(n)&Node.DOCUMENT_POSITION_FOLLOWING)return!0}return!1}},{key:"getIteratorNode",value:function(e){var t=e.previousNode(),n=void 0;return n=null===t?e.nextNode():e.nextNode()&&e.nextNode(),{prevNode:t,node:n}}},{key:"checkIframeFilter",value:function(e,t,n,r){var i=!1,o=!1;return r.forEach(function(e,t){e.val===n&&(i=t,o=e.handled)}),this.compareNodeIframe(e,t,n)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:n,handled:!0}),!0):(!1===i&&r.push({val:n,handled:!1}),!1)}},{key:"handleOpenIframes",value:function(e,t,n,r){var i=this;e.forEach(function(e){e.handled||i.getIframeContents(e.val,function(e){i.createInstanceOnIframe(e).forEachNode(t,n,r)})})}},{key:"iterateThroughNodes",value:function(e,t,n,r,i){for(var o=this,a=this.createIterator(t,e,r),s=[],c=[],u=void 0,l=void 0;function(){var e=o.getIteratorNode(a);return l=e.prevNode,u=e.node}();)this.iframes&&this.forEachIframe(t,function(e){return o.checkIframeFilter(u,l,e,s)},function(t){o.createInstanceOnIframe(t).forEachNode(e,function(e){return c.push(e)},r)}),c.push(u);c.forEach(function(e){n(e)}),this.iframes&&this.handleOpenIframes(s,e,n,r),i()}},{key:"forEachNode",value:function(e,t,n){var r=this,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:function(){},o=this.getContexts(),a=o.length;a||i(),o.forEach(function(o){var s=function(){r.iterateThroughNodes(e,o,t,n,function(){--a<=0&&i()})};r.iframes?r.waitForIframes(o,s):s()})}}],[{key:"matches",value:function(e,t){var n="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){var i=!1;return n.every(function(t){return!r.call(e,t)||(i=!0,!1)}),i}return!1}}]),e}();return n.fn.mark=function(e,t){return new r(this.get()).mark(e,t),this},n.fn.markRegExp=function(e,t){return new r(this.get()).markRegExp(e,t),this},n.fn.markRanges=function(e,t){return new r(this.get()).markRanges(e,t),this},n.fn.unmark=function(e){return new r(this.get()).unmark(e),this},n},window,document); \ No newline at end of file From c487a95d24486ce0c8309519b0c9a814c0f5e01b Mon Sep 17 00:00:00 2001 From: Phaiax Date: Tue, 3 Oct 2017 21:52:56 +0200 Subject: [PATCH 05/14] Search: Refactor, refine history behaviour, add breadcrumbs --- src/theme/book.css | 11 + src/theme/book.js | 493 +++++++++++++++++++++++++++------------------ 2 files changed, 306 insertions(+), 198 deletions(-) diff --git a/src/theme/book.css b/src/theme/book.css index a2c4fbda0f..71287a5b87 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -212,6 +212,17 @@ ul#searchresults { ul#searchresults li { margin: 10px 0px; } +ul#searchresults span.breadcrumbs { + float: right; + color: #CCC; + font-size: 0.9em; + margin-left: 10px; +} +ul#searchresults span.teaser { + display: block; + clear: both; + margin: 5px 0 0 20px; +} .menu-title { position: absolute; display: block; diff --git a/src/theme/book.js b/src/theme/book.js index 8443cd68dd..679532f9dc 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -1,179 +1,329 @@ $( document ).ready(function() { - // Helpers for searching - function create_test_searchindex() { - var searchindex = elasticlunr(function () { - this.addField('body'); - this.addField('title'); - this.setRef('id'); - }); - var content = $("#content"); - var paragraphs = content.children(); - var curr_title = ""; - var curr_body = ""; - var curr_ref = ""; - var push = function(ref) { - if ((curr_title.length > 0 || curr_body.length > 0) && curr_ref.length > 0) { - var doc = { - "id": curr_ref, - "body": curr_body, - "title": curr_title - } - searchindex.addDoc(doc); + // Search functionality + // + // Usage: call init() on startup. You can use hasFocus() to disable prevent keyhandling + // while the user is typing his search. + var search = { + searchbar : $('#searchbar'), + searchbar_outer : $('#searchbar-outer'), + searchresults : $('#searchresults'), + searchresults_outer : $("#searchresults-outer"), + searchresults_header : $("#searchresults-header"), + searchicon : $("#search-icon"), + content : $('#content'), + + searchindex : null, + searchoptions : { + bool: "AND", + expand: true, + fields: { + title: {boost: 1}, + body: {boost: 1}, + breadcrumbs: {boost: 0} } - curr_body = ""; - curr_title = ""; - curr_ref = ""; - }; - paragraphs.each(function(index, element) { - // todo uppercase - var el = $(element); - if (el.prop('nodeName').toUpperCase() == "A") { - // new header, push old paragraph to index - push(index); - curr_title = el.text(); - curr_ref = el.attr('href'); + }, + mark_exclude : [], // ['.hljs'] + current_searchterm : "", + teaser_size_half : 80, + resultcount_limit : 30, + SEARCH_PARAM : 'search', + MARK_PARAM : 'highlight', + + SEARCH_HOTKEY_KEYCODE: 83, + ESCAPE_KEYCODE: 27, + + formatSearchMetric : function(count, searchterm) { + if (count == 1) { + return count + " search result for '" + searchterm + "':"; + } else if (count == 0) { + return "No search results for '" + searchterm + "'."; } else { - curr_body += " \n " + el.text(); - } - // last paragraph - if (index == paragraphs.length - 1) { - push(index); + return count + " search results for '" + searchterm + "':"; } - }); - return searchindex; - } - - function parseURL(url) { - var a = document.createElement('a'); - a.href = url; - return { - source: url, - protocol: a.protocol.replace(':',''), - host: a.hostname, - port: a.port, - params: (function(){ - var ret = {}; - var seg = a.search.replace(/^\?/,'').split('&'); - var len = seg.length, i = 0, s; - for (;i 0 || curr_body.length > 0) && curr_ref.length > 0) { + var doc = { + "id": curr_ref, + "body": curr_body, + "title": curr_title, + "breadcrumbs": base_breadcrumbs //"Header1 » Header2" + } + searchindex.addDoc(doc); + } + curr_body = ""; + curr_title = ""; + curr_ref = ""; + }; + paragraphs.each(function(index, element) { + // todo uppercase + var el = $(element); + if (el.prop('nodeName').toUpperCase() == "A") { + // new header, push old paragraph to index + push(index); + curr_title = el.text(); + curr_ref = el.attr('href'); + } else { + curr_body += " \n " + el.text(); + } + // last paragraph + if (index == paragraphs.length - 1) { + push(index); + } + }); + this.searchindex = searchindex; } - if (urlobject.hash != "") { - url += "#" + urlobject.hash; + , + parseURL : function (url) { + var a = document.createElement('a'); + a.href = url; + return { + source: url, + protocol: a.protocol.replace(':',''), + host: a.hostname, + port: a.port, + params: (function(){ + var ret = {}; + var seg = a.search.replace(/^\?/,'').split('&'); + var len = seg.length, i = 0, s; + for (;i 0) { - searchheader = results.length + " search results for '" + searchterm + "':"; - } else if (results.length == 1) { - searchheader = results.length + " search result for '" + searchterm + "':"; - } else { - searchheader = "No search results for '" + searchterm + "'."; + , + renderURL : function (urlobject) { + var url = urlobject.protocol + "://" + urlobject.host; + if (urlobject.port != "") { + url += ":" + urlobject.port; + } + url += urlobject.path; + var joiner = "?"; + for(var prop in urlobject.params) { + if(urlobject.params.hasOwnProperty(prop)) { + url += joiner + prop + "=" + urlobject.params[prop]; + joiner = "&"; + } + } + if (urlobject.hash != "") { + url += "#" + urlobject.hash; + } + return url; } - $('#searchresults-header').text(searchheader); - - // Clear and insert results - var firstterm = searchterm.split(' ')[0]; - display.empty(); - for(var i = 0, size = results.length; i < size ; i++){ - var result = results[i]; - var firstoccurence = result.doc.body.search(firstterm); + , + formatSearchResult : function (result, searchterms) { + // Show text around first occurrence of first search term. + var firstoccurence = result.doc.body.search(searchterms[0]); var teaser = ""; if (firstoccurence != -1) { - var teaserstartindex = firstoccurence - teaser_size_half; + var teaserstartindex = firstoccurence - this.teaser_size_half; var nextwordindex = result.doc.body.indexOf(" ", teaserstartindex); if (nextwordindex != -1) { teaserstartindex = nextwordindex; } - var teaserendindex = firstoccurence + teaser_size_half; + var teaserendindex = firstoccurence + this.teaser_size_half; nextwordindex = result.doc.body.indexOf(" ", teaserendindex); if (nextwordindex != -1) { teaserendindex = nextwordindex; } - teaser = (teaserstartindex > 0) ? "..." : ""; - teaser += result.doc.body.substring(teaserstartindex, teaserendindex) + "..."; + teaser = (teaserstartindex > 0) ? "... " : ""; + teaser += result.doc.body.substring(teaserstartindex, teaserendindex) + " ..."; } else { - teaser = result.doc.body.substr(0, 80) + "..."; + teaser = result.doc.body.substr(0, this.teaser_size_half * 2) + " ..."; } + // The ?MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor var url = result.ref.split("#"); if (url.length == 1) { url.push(""); } - display.append('
  • ' - + result.doc.title + ': ' + teaser + "
  • "); + return $('
  • ' + result.doc.title + '' + + '' + result.doc.breadcrumbs + '' + + '' + teaser + '' + + '
  • '); } + , + doSearch : function (searchterm) { - // Display and scroll to results - $("#menu-bar").scrollTop(0); - $("#searchresults-outer").slideDown(); - } + // Don't search the same twice + if (this.current_searchterm == searchterm) { return; } + else { this.current_searchterm = searchterm; } - function doSearchOrHighlightFromUrl() { - // Check current URL for search request - var url = parseURL(window.location.href); - if (url.params.hasOwnProperty('search')) { - $("#searchbar-outer").slideDown(); - $("#searchbar")[0].value = url.params['search']; - $("#searchbar").trigger('keyup'); - } else { - $("#searchbar-outer").slideUp(); + if (this.searchindex == null) { return; } + + // Do the actual search + var results = this.searchindex.search(searchterm, this.searchoptions); + var resultcount = (results.length > this.resultcount_limit) + ? this.resultcount_limit : results.length; + + // Display search metrics + this.searchresults_header.text(this.formatSearchMetric(resultcount, searchterm)); + + // Clear and insert results + var searchterms = searchterm.split(' '); + this.searchresults.empty(); + for(var i = 0; i < resultcount ; i++){ + this.searchresults.append(this.formatSearchResult(results[i], searchterms)); + } + + // Display and scroll to results + this.searchresults_outer.slideDown(); + // this.searchicon.scrollTop(0); } + , + doSearchOrMarkFromUrl : function () { + // Check current URL for search request + var url = this.parseURL(window.location.href); + if (url.params.hasOwnProperty(this.SEARCH_PARAM) + && url.params[this.SEARCH_PARAM] != "") { + this.searchbar_outer.slideDown(); + this.searchbar[0].value = url.params[this.SEARCH_PARAM]; + this.searchbarKeyUpHandler(); + } else { + this.searchbar_outer.slideUp(); + } - if (url.params.hasOwnProperty('highlight')) { - var words = url.params['highlight'].split(' '); - var header = $('#' + url.hash); - $('.content').mark(words, { - // exclude : ['.hljs'] - }); + if (url.params.hasOwnProperty(this.MARK_PARAM)) { + var words = url.params[this.MARK_PARAM].split(' '); + var header = $('#' + url.hash); + this.content.mark(words, { + exclude : this.mark_exclude + }); + } } - } + , + init : function () { + // For testing purposes: Index current page + this.create_test_searchindex(); + + // Set up events + var this_ = this; + this.searchicon.click( function(e) { this_.searchIconClickHandler(); } ); + this.searchbar.on('keyup', function(e) { this_.searchbarKeyUpHandler(); } ); + $(document).on('keydown', function (e) { this_.globalKeyHandler(e); }); + // If the user uses the browser buttons, do the same as if a reload happened + window.onpopstate = function(e) { this_.doSearchOrMarkFromUrl(); }; + + // If reloaded, do the search or mark again, depending on the current url parameters + this.doSearchOrMarkFromUrl(); + } + , + hasFocus : function () { + return this.searchbar.is(':focus'); + } + , + globalKeyHandler : function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (e.keyCode == this.ESCAPE_KEYCODE) { + e.preventDefault(); + this.searchbar.removeClass("active"); + // this.searchbar[0].value = ""; + this.setSearchUrlParameters("", + (this.searchbar[0].value.trim() != 0) ? "push" : "replace"); + this.unfocusSearchbar(); + this.searchbar_outer.slideUp(); + return; + } + if (!this.hasFocus() && e.keyCode == this.SEARCH_HOTKEY_KEYCODE) { + e.preventDefault(); + this.searchbar_outer.slideDown() + this.searchbar.focus(); + } + } + , + unfocusSearchbar : function () { + // hacky, but just focusing a div only works once + var tmp = $(''); + tmp.insertAfter(this.searchicon); + tmp.focus(); + tmp.remove(); + } + , + searchIconClickHandler : function () { + this.searchbar_outer.slideToggle(); + this.searchbar.focus(); + // TODO: + // If invisible, clear URL search parameter + } + , + searchbarKeyUpHandler : function () { + var searchterm = this.searchbar[0].value.trim(); + if (searchterm != "") { + this.searchbar.addClass("active"); + this.doSearch(searchterm); + } else { + this.searchbar.removeClass("active"); + this.searchresults_outer.slideUp(); + this.searchresults.empty(); + } + + this.setSearchUrlParameters(searchterm, "if_begin_search"); + // Remove marks + this.content.unmark(); + } + , + setSearchUrlParameters : function(searchterm, action) { + // Update url with ?SEARCH_PARAM= parameter, remove ?MARK_PARAM and #heading-anchor + var url = this.parseURL(window.location.href); + var first_search = ! url.params.hasOwnProperty(this.SEARCH_PARAM); + if (searchterm != "" || action == "if_begin_search") { + url.params[this.SEARCH_PARAM] = searchterm; + delete url.params[this.MARK_PARAM]; + url.hash = ""; + } else { + delete url.params[this.SEARCH_PARAM]; + } + // A new search will also add a new history item, so the user can go back + // to the page prior to searching. A updated search term will only replace + // the url. + if (action == "push" || (action == "if_begin_search" && first_search) ) { + history.pushState({}, document.title, this.renderURL(url)); + } else if (action == "replace" || (action == "if_begin_search" && !first_search) ) { + history.replaceState({}, document.title, this.renderURL(url)); + } + + } + }; + + // Interesting DOM Elements + var sidebar = $("#sidebar"); // url var url = window.location.pathname; @@ -213,13 +363,12 @@ $( document ).ready(function() { var KEY_CODES = { PREVIOUS_KEY: 37, - NEXT_KEY: 39, - SEARCH_KEY: 83 + NEXT_KEY: 39 }; $(document).on('keydown', function (e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } - if ($('#searchbar').is( ":focus" )) { return; } + if (search.hasFocus()) { return; } switch (e.keyCode) { case KEY_CODES.NEXT_KEY: e.preventDefault(); @@ -233,17 +382,9 @@ $( document ).ready(function() { window.location.href = $('.nav-chapters.previous').attr('href'); } break; - case KEY_CODES.SEARCH_KEY: - e.preventDefault(); - $("#searchbar-outer").slideDown() - $('#searchbar').focus(); - break; } }); - // Interesting DOM Elements - var sidebar = $("#sidebar"); - // Help keyboard navigation by always focusing on page content $(".page").focus(); @@ -264,52 +405,8 @@ $( document ).ready(function() { sidebar.scrollTop(activeSection.offset().top); } - // For testing purposes: Index current page - var searchindex = create_test_searchindex(); - - $("#search-icon").click(function(e) { - var outer = $("#searchbar-outer"); - outer.slideToggle(); - // TODO: - // If invisible, clear URL search parameter - }); - - // Searchbar - $("#searchbar").on('keyup', function (e) { - var display = $('#searchresults'); - var outer = $("#searchresults-outer"); - - var searchterm = e.target.value.trim(); - if (searchterm != "") { - $(e.target).addClass("active"); - - doSearch(searchindex, searchterm); - } else { - $(e.target).removeClass("active"); - outer.slideUp(); - display.empty(); - } - - var url = parseURL(window.location.href); - var first_search = ! url.params.hasOwnProperty("search"); - url.params["search"] = searchterm; - delete url.params["highlight"]; - url.hash = ""; - if (first_search) { - history.pushState({}, document.title, renderURL(url)); - } else { - history.replaceState({}, document.title, renderURL(url)); - } - $('.content').unmark(); - }); - - window.onpopstate = function(e) { - doSearchOrHighlightFromUrl(); - }; - - doSearchOrHighlightFromUrl(); - - + // Search + search.init(); // Theme button $("#theme-toggle").click(function(){ From aa1f02f7b2e197e2a01eb65b14caf49ce2d32ccb Mon Sep 17 00:00:00 2001 From: Phaiax Date: Wed, 4 Oct 2017 18:01:57 +0200 Subject: [PATCH 06/14] Search: Style the other themes, emphase active header. --- src/theme/book.css | 82 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/theme/book.css b/src/theme/book.css index 71287a5b87..28cabb9de9 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -36,6 +36,15 @@ h5 { .header + .header h5 { margin-top: 1em; } +a.header:target h1:before, +a.header:target h2:before, +a.header:target h3:before, +a.header:target h4:before { + display: inline-block; + content: "»"; + margin-left: -30px; + width: 30px; +} table { margin: 0 auto; border-collapse: collapse; @@ -212,11 +221,11 @@ ul#searchresults { ul#searchresults li { margin: 10px 0px; } -ul#searchresults span.breadcrumbs { +ul#searchresults .breadcrumbs { float: right; - color: #CCC; font-size: 0.9em; margin-left: 10px; + padding: 2px 0 0 0; } ul#searchresults span.teaser { display: block; @@ -421,6 +430,19 @@ ul#searchresults span.teaser { .light a > .hljs { color: #4183c4; } +.light #searchbar { + border: 1px solid #AAA; + background-color: #FAFAFA; +} +.light .searchresults-header { + color: #666; +} +.light .searchresults-outer { + border-bottom-color: #888; +} +.light .breadcrumbs { + color: #CCC; +} .light mark { background-color: #a2cff5; } @@ -546,6 +568,19 @@ ul#searchresults span.teaser { .coal a > .hljs { color: #2b79a2; } +.coal #searchbar { + border: 1px solid #AAA; + background-color: #B7B7B7; +} +.coal .searchresults-header { + color: #666; +} +.coal .searchresults-outer { + border-bottom-color: #98a3ad; +} +.coal .breadcrumbs { + color: #686868; +} .coal mark { background-color: #355c7d; } @@ -671,6 +706,19 @@ ul#searchresults span.teaser { .navy a > .hljs { color: #2b79a2; } +.navy #searchbar { + border: 1px solid #AAA; + background-color: #aeaec6; +} +.navy .searchresults-header { + color: #5f5f71; +} +.navy .searchresults-outer { + border-bottom-color: #5c5c68; +} +.navy .breadcrumbs { + color: #5c5c68; +} .navy mark { background-color: #a2cff5; } @@ -796,6 +844,19 @@ ul#searchresults span.teaser { .rust a > .hljs { color: #2b79a2; } +.rust #searchbar { + border: 1px solid #AAA; + background-color: #FAFAFA; +} +.rust .searchresults-header { + color: #666; +} +.rust .searchresults-outer { + border-bottom-color: #888; +} +.rust .breadcrumbs { + color: #757575; +} .rust mark { background-color: #e69f67; } @@ -921,6 +982,23 @@ ul#searchresults span.teaser { .ayu a > .hljs { color: #0096cf; } +.ayu #searchbar { + border: 1px solid #848484; + background-color: #424242; + color: #FFF; +} +.ayu #searchbar:focus, .ayu #searchbar.active { + box-shadow: 0 0 5px #D4C89F; +} +.ayu .searchresults-header { + color: #666; +} +.ayu .searchresults-outer { + border-bottom-color: #888; +} +.ayu .breadcrumbs { + color: #5f5f5f; +} .ayu mark { background-color: #e3b171; } From a198e99fa9fad7d16d3db12a372bf26805db279a Mon Sep 17 00:00:00 2001 From: Phaiax Date: Mon, 9 Oct 2017 13:03:21 +0200 Subject: [PATCH 07/14] Search: Fine tuning * remove searchindex feature (nightly requirement of elasticlunr-rs dropped) * some documentation * refactor BookItems iterator * add iterator for parents * Include paragraph structure in hierarchy * Fix url and specialchar handling * Use complete index --- Cargo.toml | 3 +- src/book/bookitem.rs | 115 ++++++++++++++----- src/book/mod.rs | 6 +- src/lib.rs | 1 - src/renderer/html_handlebars/hbs_renderer.rs | 74 ++++++++---- src/theme/book.js | 68 ++++++++--- src/utils/mod.rs | 60 ++++++---- 7 files changed, 228 insertions(+), 99 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fbdeaff8b6..7d7f048d21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ toml = "0.4" open = "1.1" regex = "0.2.1" tempdir = "0.3.4" -elasticlunr = { git = "https://github.com/mattico/elasticlunr-rs", optional = true} +elasticlunr = { git = "https://github.com/mattico/elasticlunr-rs" } # Watch feature notify = { version = "4.0", optional = true } @@ -56,7 +56,6 @@ output = [] regenerate-css = [] watch = ["notify", "time", "crossbeam"] serve = ["iron", "staticfile", "ws"] -searchindex = ["elasticlunr"] [[bin]] doc = false diff --git a/src/book/bookitem.rs b/src/book/bookitem.rs index a2ec2cb0ed..4d16cc19be 100644 --- a/src/book/bookitem.rs +++ b/src/book/bookitem.rs @@ -2,7 +2,12 @@ use serde::{Serialize, Serializer}; use serde::ser::SerializeStruct; use std::path::PathBuf; - +/// A BookItem corresponds to one entry of the table of contents file SUMMARY.md. +/// A line in that file can either be a numbered chapter with a section number like 2.1.3 or a +/// suffix or postfix chapter without such a section number. +/// The `String` field in the `Chapter` variant contains the section number as `2.1.3`. +/// The `Chapter` type contains the child elements (which can only be other `BookItem::Chapters`). +/// `BookItem::Affix` and `BookItem::Spacer` are only allowed within the root level. #[derive(Debug, Clone)] pub enum BookItem { Chapter(String, Chapter), // String = section @@ -10,6 +15,9 @@ pub enum BookItem { Spacer, } +/// A chapter is a `.md` file that is referenced by some line in the `SUMMARY.md` table of +/// contents. It also has references to its sub chapters via `sub_items`. These items can +/// only be of the variant `BookItem::Chapter`. #[derive(Debug, Clone)] pub struct Chapter { pub name: String, @@ -17,13 +25,21 @@ pub struct Chapter { pub sub_items: Vec, } +/// A flattening, depth-first iterator over Bookitems and it's children. +/// It can be obtained by calling `MDBook::iter()`. #[derive(Debug, Clone)] pub struct BookItems<'a> { - pub items: &'a [BookItem], - pub current_index: usize, - pub stack: Vec<(&'a [BookItem], usize)>, + /// The remaining items in the iterator in the current, deepest level of the iterator + items: &'a [BookItem], + /// The higher levels of the hierarchy. The parents of the current level are still + /// in the list and accessible as `[stack[0][0], stack[1][0], stack[2][0], ...]`. + stack: Vec<&'a [BookItem]>, } +/// Iterator for the parent `BookItem`s of a `BookItem`. +pub struct BookItemParents<'a> { + stack: &'a [ &'a [BookItem] ] +} impl Chapter { pub fn new(name: String, path: PathBuf) -> Self { @@ -48,39 +64,78 @@ impl Serialize for Chapter { } } - - -// Shamelessly copied from Rustbook -// (https://github.com/rust-lang/rust/blob/master/src/rustbook/book.rs) impl<'a> Iterator for BookItems<'a> { type Item = &'a BookItem; fn next(&mut self) -> Option<&'a BookItem> { - loop { - if self.current_index >= self.items.len() { - match self.stack.pop() { - None => return None, - Some((parent_items, parent_idx)) => { - self.items = parent_items; - self.current_index = parent_idx + 1; - } - } - } else { - let cur = &self.items[self.current_index]; - - match *cur { - BookItem::Chapter(_, ref ch) | BookItem::Affix(ref ch) => { - self.stack.push((self.items, self.current_index)); + if let Some((first, rest)) = self.items.split_first() { + // Return the first element in `items` and optionally dive into afterwards. + match first { + &BookItem::Spacer => { + self.items = rest; + }, + &BookItem::Chapter(_, ref ch) | + &BookItem::Affix(ref ch) => { + if ch.sub_items.is_empty() { + self.items = rest; + } else { + // Don't remove `first` for now. (Because of Parent Iterator) + self.stack.push(self.items); self.items = &ch.sub_items[..]; - self.current_index = 0; } - BookItem::Spacer => { - self.current_index += 1; - } - } - - return Some(cur); + }, + }; + Some(first) + } else { + // Current level is drained => pop from `stack` or return `None` + if let Some(stacked_items) = self.stack.pop() { + // The first item of the popped slice is the bookitem we previously dived into. + self.items = &stacked_items[1..]; + self.next() + } else { + None } } } } + +impl<'a> BookItems<'a> { + pub fn new(items : &'a[BookItem]) -> BookItems<'a> { + BookItems { + items : items, + stack : vec![], + } + } + + /// Returns an iterator to iterate the parents of the last yielded `BookItem`. + /// Starts with the root item. + pub fn current_parents(&'a self) -> BookItemParents<'a> { + BookItemParents { stack : &self.stack } + } + + /// Collects the names of the parent `BookItem`s of the last yielded `Bookitem` into a list. + pub fn collect_current_parents_names(&self) -> Vec { + self.current_parents().filter_map(|i| match i { + &BookItem::Chapter(_, ref ch) | &BookItem::Affix(ref ch) => Some(ch.name.clone()), + _ => None, + }).collect() + } + + /// Get the level of the last yielded `BookItem`. Root level = 0 + pub fn current_depth(&'a self) -> usize { + self.stack.len() + } +} + +impl<'a> Iterator for BookItemParents<'a> { + type Item = &'a BookItem; + + fn next(&mut self) -> Option<&'a BookItem> { + if let Some((first, rest)) = self.stack.split_first() { + self.stack = rest; + Some (&first[0]) + } else { + None + } + } +} \ No newline at end of file diff --git a/src/book/mod.rs b/src/book/mod.rs index fc757a900c..9a1cf95ffc 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -105,11 +105,7 @@ impl MDBook { /// ``` pub fn iter(&self) -> BookItems { - BookItems { - items: &self.content[..], - current_index: 0, - stack: Vec::new(), - } + BookItems::new(&self.content[..]) } /// `init()` creates some boilerplate files and directories diff --git a/src/lib.rs b/src/lib.rs index cc2c7771bd..00e5cabe87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,7 +88,6 @@ extern crate serde_derive; extern crate serde_json; extern crate tempdir; extern crate toml; -#[cfg(feature = "searchindex")] extern crate elasticlunr; mod parse; diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index d0e2fc326d..59ba412a34 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -9,7 +9,6 @@ use theme::{Theme, playpen_editor}; use errors::*; use regex::{Captures, Regex}; -#[cfg(feature = "searchindex")] use elasticlunr; use std::ascii::AsciiExt; @@ -35,13 +34,15 @@ impl HtmlHandlebars { item: &BookItem, mut ctx: RenderItemContext, print_content: &mut String, - search_documents : &mut Vec) + search_documents : &mut Vec, + mut parents_names : Vec) -> Result<()> { + // FIXME: This should be made DRY-er and rely less on mutable state match *item { - BookItem::Chapter(_, ref ch) | BookItem::Affix(ref ch) - if !ch.path.as_os_str().is_empty() => - { + BookItem::Chapter(_, ref ch) | + BookItem::Affix(ref ch) if !ch.path.as_os_str().is_empty() => { + let path = ctx.book.get_source().join(&ch.path); let content = utils::fs::file_to_string(&path)?; let base = path.parent() @@ -49,11 +50,20 @@ impl HtmlHandlebars { let path = ch.path.to_str().ok_or_else(|| { io::Error::new(io::ErrorKind::Other, "Could not convert path to str") })?; + let filepath = Path::new(&ch.path).with_extension("html"); + let filepath = filepath.to_str().ok_or_else(|| { + Error::from(format!("Bad file name: {}", filepath.display())) + })?; + + if ! parents_names.last().map(String::as_ref).unwrap_or("") + .eq_ignore_ascii_case(&ch.name) { + parents_names.push(ch.name.clone()); + } utils::render_markdown_into_searchindex(search_documents, &content, - path, - &vec![], + filepath, + parents_names, id_from_content); // Parse and expand links @@ -84,17 +94,15 @@ impl HtmlHandlebars { debug!("[*]: Render template"); let rendered = ctx.handlebars.render("index", &ctx.data)?; - let filepath = Path::new(&ch.path).with_extension("html"); + let rendered = self.post_process( rendered, - &normalize_path(filepath.to_str().ok_or_else(|| Error::from( - format!("Bad file name: {}", filepath.display()), - ))?), + &normalize_path(filepath), &ctx.book.config.html_config().unwrap_or_default().playpen, ); // Write to file - info!("[*] Creating {:?} ✓", filepath.display()); + info!("[*] Creating {:?} ✓", filepath); ctx.book.write_file(filepath, &rendered.into_bytes())?; if ctx.is_index { @@ -282,20 +290,28 @@ impl Renderer for HtmlHandlebars { fs::create_dir_all(&destination) .chain_err(|| "Unexpected error when constructing destination path")?; - for (i, item) in book.iter().enumerate() { + + let mut depthfirstiterator = book.iter(); + let mut is_index = true; + while let Some(item) = depthfirstiterator.next() { let ctx = RenderItemContext { book: book, handlebars: &handlebars, destination: destination.to_path_buf(), data: data.clone(), - is_index: i == 0, + is_index: is_index, html_config: html_config.clone(), }; - self.render_item(item, ctx, &mut print_content, &mut search_documents)?; + self.render_item(item, + ctx, + &mut print_content, + &mut search_documents, + depthfirstiterator.collect_current_parents_names())?; + is_index = false; } // Search index - make_searchindex(book, &search_documents)?; + make_searchindex(book, search_documents)?; // Print version self.configure_print_version(&mut data, &print_content); @@ -633,21 +649,29 @@ pub fn normalize_id(content: &str) -> String { .collect::() } -#[cfg(not(feature = "searchindex"))] -fn make_searchindex(_book: &MDBook, _search_documents : &Vec) -> Result<()> { - Ok(()) -} +/// Uses elasticlunr to create a search index and exports that into `searchindex.json`. +fn make_searchindex(book: &MDBook, search_documents : Vec) -> Result<()> { + let mut index = elasticlunr::index::Index::new("id", + &["title".into(), "body".into(), "breadcrumbs".into()]); -#[cfg(feature = "searchindex")] -fn make_searchindex(book: &MDBook, search_documents : &Vec) -> Result<()> { - let mut index = elasticlunr::IndexBuilder::new(); for sd in search_documents { - index.add_document(&sd.title, &sd.body); + let anchor = if let Some(s) = sd.anchor.1 { + format!("{}#{}", sd.anchor.0, &s) + } else { + sd.anchor.0 + }; + + let mut map = HashMap::new(); + map.insert("id".into(), anchor.clone()); + map.insert("title".into(), sd.title); + map.insert("body".into(), sd.body); + map.insert("breadcrumbs".into(), sd.hierarchy.join(" » ")); + index.add_doc(&anchor, map); } book.write_file( Path::new("searchindex").with_extension("json"), - &index.to_json().as_bytes(), + &serde_json::to_string(&index).unwrap().as_bytes(), )?; info!("[*] Creating \"searchindex.json\" ✓"); diff --git a/src/theme/book.js b/src/theme/book.js index 679532f9dc..ceaa14b570 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -144,6 +144,20 @@ $( document ).ready(function() { return url; } , + escapeHTML: (function() { + var MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + var repl = function(c) { return MAP[c]; }; + return function(s) { + return s.replace(/[&<>'"]/g, repl); + }; + })() + , formatSearchResult : function (result, searchterms) { // Show text around first occurrence of first search term. var firstoccurence = result.doc.body.search(searchterms[0]); @@ -173,9 +187,9 @@ $( document ).ready(function() { return $('
  • ' + result.doc.title + '' - + '' + result.doc.breadcrumbs + '' - + '' + teaser + '' + + '">' + result.doc.breadcrumbs + '' // doc.title + + '' + '' + + '' + this.escapeHTML(teaser) + '' + '
  • '); } , @@ -213,7 +227,8 @@ $( document ).ready(function() { if (url.params.hasOwnProperty(this.SEARCH_PARAM) && url.params[this.SEARCH_PARAM] != "") { this.searchbar_outer.slideDown(); - this.searchbar[0].value = url.params[this.SEARCH_PARAM]; + this.searchbar[0].value = decodeURIComponent( + (url.params[this.SEARCH_PARAM]+'').replace(/\+/g, '%20')); this.searchbarKeyUpHandler(); } else { this.searchbar_outer.slideUp(); @@ -229,19 +244,42 @@ $( document ).ready(function() { } , init : function () { + var this_ = this; + window.md = this; + // For testing purposes: Index current page - this.create_test_searchindex(); + //this.create_test_searchindex(); + + $.getJSON("searchindex.json", function(json) { + //this_.searchindex = elasticlunr.Index.load(json); + + // TODO: Workaround: reindex everything + var searchindex = elasticlunr(function () { + this.addField('body'); + this.addField('title'); + this.addField('breadcrumbs') + this.setRef('id'); + }); + window.mjs = json; + var docs = json.documentStore.docs; + for (var key in docs) { + searchindex.addDoc(docs[key]); + } + this_.searchindex = searchindex; + + + // Set up events + this_.searchicon.click( function(e) { this_.searchIconClickHandler(); } ); + this_.searchbar.on('keyup', function(e) { this_.searchbarKeyUpHandler(); } ); + $(document).on('keydown', function (e) { this_.globalKeyHandler(e); }); + // If the user uses the browser buttons, do the same as if a reload happened + window.onpopstate = function(e) { this_.doSearchOrMarkFromUrl(); }; + + // If reloaded, do the search or mark again, depending on the current url parameters + this_.doSearchOrMarkFromUrl(); + + }); - // Set up events - var this_ = this; - this.searchicon.click( function(e) { this_.searchIconClickHandler(); } ); - this.searchbar.on('keyup', function(e) { this_.searchbarKeyUpHandler(); } ); - $(document).on('keydown', function (e) { this_.globalKeyHandler(e); }); - // If the user uses the browser buttons, do the same as if a reload happened - window.onpopstate = function(e) { this_.doSearchOrMarkFromUrl(); }; - - // If reloaded, do the search or mark again, depending on the current url parameters - this.doSearchOrMarkFromUrl(); } , hasFocus : function () { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index fa77e0f5d6..dfdb620fe4 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,10 +2,10 @@ pub mod fs; use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES, OPTION_ENABLE_TABLES}; +use std::ascii::AsciiExt; use std::borrow::Cow; use std::fmt::Write; use regex::Regex; -use std::rc::Rc; /// A heading together with the successive content until the next heading will /// make up one `SearchDocument`. It represents some independently searchable part of the book. @@ -16,22 +16,22 @@ pub struct SearchDocument { // Content: Flatted paragraphs, lists, code pub body : String, /// Needed information to generate a link to the corresponding title anchor - /// First part is the `reference_base` that should be the same for all documents that + /// First part is the `anchor_base` that should be the same for all documents that /// came from the same `.md` file. The second part is derived from the heading of the search /// document. - pub sref : (Rc, Option), - // Breadcrumbs like ["Main Chapter Title", "Sub Chapter Title", "H1 Heading"] + pub anchor : (String, Option), + // Hierarchy like ["Main Chapter Title", "Sub Chapter Title", "H1 Heading"] // as a human understandable path to the search document. - pub breadcrumbs : Vec>, + pub hierarchy : Vec, } impl SearchDocument { - fn new(sref0 : &Rc, bcs : &Vec>) -> SearchDocument { + fn new(anchor_base : &str, hierarchy : &Vec) -> SearchDocument { SearchDocument { title : "".to_owned(), body : "".to_owned(), - sref : (sref0.clone(), None), - breadcrumbs : bcs.clone() + anchor : (anchor_base.to_owned(), None), + hierarchy : (*hierarchy).clone() } } @@ -47,19 +47,29 @@ impl SearchDocument { self.body.write_str(&" ").unwrap(); } } + + fn extend_hierarchy(&mut self, more : &Vec) { + let last = self.hierarchy.last().map(String::as_ref).unwrap_or("").to_owned(); + + self.hierarchy.extend(more.iter().filter(|h| + h.as_str() != "" + && ! h.as_str().eq_ignore_ascii_case(&last)) + .map(|h| h.to_owned())); + + } } /// Renders markdown into flat unformatted text for usage in the search index. /// Refer to the struct `SearchDocument`. /// -/// The field `sref` in the `SearchDocument` struct becomes -/// `(reference_base, Some(heading_to_sref("The Section Heading")))` +/// The field `anchor` in the `SearchDocument` struct becomes +/// `(anchor_base, Some(heading_to_anchor("The Section Heading")))` pub fn render_markdown_into_searchindex( search_documents: &mut Vec, text: &str, - reference_base: &str, - breadcrumbs : &Vec>, - heading_to_sref : F) + anchor_base: &str, + hierarchy : Vec, + heading_to_anchor : F) where F : Fn(&str) -> String { let mut opts = Options::empty(); @@ -67,24 +77,31 @@ pub fn render_markdown_into_searchindex( opts.insert(OPTION_ENABLE_FOOTNOTES); let p = Parser::new_ext(text, opts); - let reference_base = Rc::new(reference_base.to_owned()); - let mut current = SearchDocument::new(&reference_base, breadcrumbs); + let mut current = SearchDocument::new(&anchor_base, &hierarchy); let mut in_header = false; + let max_paragraph_level = 3; + let mut header_hierarchy = vec!["".to_owned(); max_paragraph_level as usize]; for event in p { match event { - Event::Start(Tag::Header(i)) if i <= 3 => { + Event::Start(Tag::Header(i)) if i <= max_paragraph_level => { + // Paragraph finished, the next header is following now if current.has_content() { + // Push header_hierarchy to the search documents chapter hierarchy + current.extend_hierarchy(&header_hierarchy); search_documents.push(current); } - current = SearchDocument::new(&reference_base, breadcrumbs); + current = SearchDocument::new(&anchor_base, &hierarchy); in_header = true; } - Event::End(Tag::Header(_)) => { - // Possible extension: Use h1,h2,h3 as hierarchy for the breadcrumbs - current.breadcrumbs.push(Rc::new(current.title.clone())); - current.sref.1 = Some(heading_to_sref(¤t.title)); + Event::End(Tag::Header(i)) if i <= max_paragraph_level => { in_header = false; + current.anchor.1 = Some(heading_to_anchor(¤t.title)); + + header_hierarchy[i as usize -1] = current.title.clone(); + for h in &mut header_hierarchy[i as usize ..] { + *h = "".to_owned(); + } } Event::Start(_) | Event::End(_) => {} Event::Text(text) => { @@ -97,6 +114,7 @@ pub fn render_markdown_into_searchindex( Event::SoftBreak | Event::HardBreak => {} } } + current.extend_hierarchy(&header_hierarchy); search_documents.push(current); } From 850df09670b04f50b7736d9c05c156f6a1561671 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Sun, 22 Oct 2017 14:48:08 +0200 Subject: [PATCH 08/14] Search: Improve search result snippets. Cancel highlight with ESC. --- src/theme/book.css | 5 ++ src/theme/book.js | 119 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/theme/book.css b/src/theme/book.css index 28cabb9de9..e1b9d9ec23 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -231,6 +231,11 @@ ul#searchresults span.teaser { display: block; clear: both; margin: 5px 0 0 20px; + font-size: 0.8em; +} +ul#searchresults span.teaser em { + font-weight: bold; + font-style: normal; } .menu-title { position: absolute; diff --git a/src/theme/book.js b/src/theme/book.js index ceaa14b570..d0a3c1db46 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -25,7 +25,7 @@ $( document ).ready(function() { }, mark_exclude : [], // ['.hljs'] current_searchterm : "", - teaser_size_half : 80, + teaser_words : 30, resultcount_limit : 30, SEARCH_PARAM : 'search', MARK_PARAM : 'highlight', @@ -160,24 +160,7 @@ $( document ).ready(function() { , formatSearchResult : function (result, searchterms) { // Show text around first occurrence of first search term. - var firstoccurence = result.doc.body.search(searchterms[0]); - var teaser = ""; - if (firstoccurence != -1) { - var teaserstartindex = firstoccurence - this.teaser_size_half; - var nextwordindex = result.doc.body.indexOf(" ", teaserstartindex); - if (nextwordindex != -1) { - teaserstartindex = nextwordindex; - } - var teaserendindex = firstoccurence + this.teaser_size_half; - nextwordindex = result.doc.body.indexOf(" ", teaserendindex); - if (nextwordindex != -1) { - teaserendindex = nextwordindex; - } - teaser = (teaserstartindex > 0) ? "... " : ""; - teaser += result.doc.body.substring(teaserstartindex, teaserendindex) + " ..."; - } else { - teaser = result.doc.body.substr(0, this.teaser_size_half * 2) + " ..."; - } + var teaser = this.makeTeaser(this.escapeHTML(result.doc.body), searchterms); // The ?MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor var url = result.ref.split("#"); @@ -189,10 +172,104 @@ $( document ).ready(function() { + url[0] + '?' + this.MARK_PARAM + '=' + searchterms + '#' + url[1] + '">' + result.doc.breadcrumbs + '' // doc.title + '' + '' - + '' + this.escapeHTML(teaser) + '' + + '' + teaser + '' + ''); } , + makeTeaser : function (body, searchterms) { + // 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 . + var stemmed_searchterms = searchterms.map(elasticlunr.stemmer); + var searchterm_weight = 40; + var weighted = []; // contains elements of ["word", weight, index_in_document] + // split in sentences, then words + var sentences = body.split('. '); + var index = 0; + var value = 0; + var searchterm_found = false; + for (var sentenceindex in sentences) { + var words = sentences[sentenceindex].split(' '); + value = 8; + for (var wordindex in words) { + var word = words[wordindex]; + if (word.length > 0) { + for (var searchtermindex in stemmed_searchterms) { + if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) { + value = searchterm_weight; + searchterm_found = true; + } + }; + weighted.push([word, value, index]); + value = 2; + } + 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 window_weight = []; + var window_size = Math.min(weighted.length, this.teaser_words); + + var cur_sum = 0; + for (var wordindex = 0; wordindex < window_size; wordindex++) { + cur_sum += weighted[wordindex][1]; + }; + window_weight.push(cur_sum); + for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) { + cur_sum -= weighted[wordindex][1]; + cur_sum += weighted[wordindex + window_size][1]; + window_weight.push(cur_sum); + }; + + if (searchterm_found) { + var max_sum = 0; + var max_sum_window_index = 0; + // backwards + for (var i = window_weight.length - 1; i >= 0; i--) { + if (window_weight[i] > max_sum) { + max_sum = window_weight[i]; + max_sum_window_index = i; + } + }; + } else { + max_sum_window_index = 0; + } + + // add around searchterms + var teaser_split = []; + var index = weighted[max_sum_window_index][2]; + for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) { + var word = weighted[i]; + if (index < word[2]) { + // missing text from index to start of `word` + teaser_split.push(body.substring(index, word[2])); + index = word[2]; + } + if (word[1] == searchterm_weight) { + teaser_split.push("") + } + index = word[2] + word[0].length; + teaser_split.push(body.substring(word[2], index)); + if (word[1] == searchterm_weight) { + teaser_split.push("") + } + }; + + return teaser_split.join(''); + } + , doSearch : function (searchterm) { // Don't search the same twice @@ -245,7 +322,6 @@ $( document ).ready(function() { , init : function () { var this_ = this; - window.md = this; // For testing purposes: Index current page //this.create_test_searchindex(); @@ -296,6 +372,7 @@ $( document ).ready(function() { (this.searchbar[0].value.trim() != 0) ? "push" : "replace"); this.unfocusSearchbar(); this.searchbar_outer.slideUp(); + this.content.unmark(); return; } if (!this.hasFocus() && e.keyCode == this.SEARCH_HOTKEY_KEYCODE) { From 4dcba1943c42663d176c6c937fe6d5036f72db7c Mon Sep 17 00:00:00 2001 From: Phaiax Date: Mon, 13 Nov 2017 22:18:13 +0100 Subject: [PATCH 09/14] Search: Configuration via book.toml --- book-example/book.toml | 12 ++- src/config.rs | 52 +++++++++- src/renderer/html_handlebars/hbs_renderer.rs | 104 +++++++++++++++---- src/theme/book.js | 21 ++-- src/theme/index.hbs | 2 - src/utils/mod.rs | 4 +- 6 files changed, 163 insertions(+), 32 deletions(-) diff --git a/book-example/book.toml b/book-example/book.toml index 4dbc659cc2..cfcb11bb2f 100644 --- a/book-example/book.toml +++ b/book-example/book.toml @@ -3,4 +3,14 @@ description = "Create book from markdown files. Like Gitbook but implemented in author = "Mathieu David" [output.html] -mathjax-support = true \ No newline at end of file +mathjax-support = true + +[output.html.search] +enable = true +limit-results = 20 +use-boolean-and = true +boost-title = 2 +boost-hierarchy = 2 +boost-paragraph = 1 +expand = true +split-until-heading = 2 diff --git a/src/config.rs b/src/config.rs index fd2ed2f0e1..7651273a6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,8 +90,8 @@ impl Config { get_and_insert!(table, "source" => cfg.book.src); get_and_insert!(table, "description" => cfg.book.description); - // This complicated chain of and_then's is so we can move - // "output.html.destination" to "book.build_dir" and parse it into a + // This complicated chain of and_then's is so we can move + // "output.html.destination" to "book.build_dir" and parse it into a // PathBuf. let destination: Option = table.get_mut("output") .and_then(|output| output.as_table_mut()) @@ -227,6 +227,7 @@ pub struct HtmlConfig { pub additional_css: Vec, pub additional_js: Vec, pub playpen: Playpen, + pub search: Search, } /// Configuration for tweaking how the the HTML renderer handles the playpen. @@ -236,6 +237,53 @@ pub struct Playpen { pub editable: bool, } +/// Configuration of the search functionality of the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Search { + /// Enable in browser searching. Default: true. + pub enable: bool, + /// Maximum number of visible results. Default: 30. + pub limit_results: u32, + /// The number of words used for a search result teaser. Default: 30, + pub teaser_word_count: u32, + /// Define the logical link between multiple search words. + /// If true, all search words must appear in each result. Default: true. + pub use_boolean_and: bool, + /// Boost factor for the search result score if a search word appears in the header. + /// Default: 2. + pub boost_title: u8, + /// Boost factor for the search result score if a search word appears in the hierarchy. + /// The hierarchy contains all titles of the parent documents and all parent headings. + /// Default: 1. + pub boost_hierarchy: u8, + /// Boost factor for the search result score if a search word appears in the text. + /// Default: 1. + pub boost_paragraph: u8, + /// True if the searchword `micro` should match `microwave`. Default: true. + pub expand : bool, + /// Documents are split into smaller parts, seperated by headings. This defines, until which + /// level of heading documents should be split. Default: 3. (`### This is a level 3 heading`) + pub split_until_heading: u8, +} + +impl Default for Search { + fn default() -> Search { + // Please update the documentation of `Search` when changing values! + Search { + enable: true, + limit_results: 30, + teaser_word_count: 30, + use_boolean_and: false, + boost_title: 2, + boost_hierarchy: 1, + boost_paragraph: 1, + expand: true, + split_until_heading: 3, + } + } +} + #[cfg(test)] mod tests { diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 59ba412a34..9b6f01fcea 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -3,7 +3,7 @@ use preprocess; use renderer::Renderer; use book::MDBook; use book::bookitem::{BookItem, Chapter}; -use config::{Config, Playpen, HtmlConfig}; +use config::{Config, Playpen, HtmlConfig, Search}; use {utils, theme}; use theme::{Theme, playpen_editor}; use errors::*; @@ -60,7 +60,9 @@ impl HtmlHandlebars { .eq_ignore_ascii_case(&ch.name) { parents_names.push(ch.name.clone()); } - utils::render_markdown_into_searchindex(search_documents, + utils::render_markdown_into_searchindex( + &ctx.html_config.search, + search_documents, &content, filepath, parents_names, @@ -311,7 +313,7 @@ impl Renderer for HtmlHandlebars { } // Search index - make_searchindex(book, search_documents)?; + make_searchindex(book, search_documents, &html_config.search)?; // Print version self.configure_print_version(&mut data, &print_content); @@ -650,28 +652,92 @@ pub fn normalize_id(content: &str) -> String { } /// Uses elasticlunr to create a search index and exports that into `searchindex.json`. -fn make_searchindex(book: &MDBook, search_documents : Vec) -> Result<()> { - let mut index = elasticlunr::index::Index::new("id", - &["title".into(), "body".into(), "breadcrumbs".into()]); +fn make_searchindex(book: &MDBook, + search_documents : Vec, + searchconfig : &Search) -> Result<()> { - for sd in search_documents { - let anchor = if let Some(s) = sd.anchor.1 { - format!("{}#{}", sd.anchor.0, &s) - } else { - sd.anchor.0 - }; - let mut map = HashMap::new(); - map.insert("id".into(), anchor.clone()); - map.insert("title".into(), sd.title); - map.insert("body".into(), sd.body); - map.insert("breadcrumbs".into(), sd.hierarchy.join(" » ")); - index.add_doc(&anchor, map); + #[derive(Serialize)] + struct SearchOptionsField { + boost: u8, + } + + #[derive(Serialize)] + struct SearchOptionsFields { + title: SearchOptionsField, + body: SearchOptionsField, + breadcrumbs: SearchOptionsField, + } + + /// The searchoptions for elasticlunr.js + #[derive(Serialize)] + struct SearchOptions { + bool: String, + expand: bool, + limit_results: u32, + teaser_word_count: u32, + fields: SearchOptionsFields, } + #[derive(Serialize)] + struct SearchindexJson { + enable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + searchoptions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + index: Option, + + } + + let searchoptions = SearchOptions { + bool : if searchconfig.use_boolean_and { "AND".into() } else { "OR".into() }, + expand : searchconfig.expand, + limit_results : searchconfig.limit_results, + teaser_word_count : searchconfig.teaser_word_count, + fields : SearchOptionsFields { + title : SearchOptionsField { boost : searchconfig.boost_title }, + body : SearchOptionsField { boost : searchconfig.boost_paragraph }, + breadcrumbs : SearchOptionsField { boost : searchconfig.boost_hierarchy }, + } + }; + + let json_contents = if searchconfig.enable { + + let mut index = elasticlunr::index::Index::new("id", + &["title".into(), "body".into(), "breadcrumbs".into()]); + + for sd in search_documents { + let anchor = if let Some(s) = sd.anchor.1 { + format!("{}#{}", sd.anchor.0, &s) + } else { + sd.anchor.0 + }; + + let mut map = HashMap::new(); + map.insert("id".into(), anchor.clone()); + map.insert("title".into(), sd.title); + map.insert("body".into(), sd.body); + map.insert("breadcrumbs".into(), sd.hierarchy.join(" » ")); + index.add_doc(&anchor, map); + } + + SearchindexJson { + enable : searchconfig.enable, + searchoptions : Some(searchoptions), + index : Some(index), + } + } else { + SearchindexJson { + enable : false, + searchoptions : None, + index : None, + } + }; + + book.write_file( Path::new("searchindex").with_extension("json"), - &serde_json::to_string(&index).unwrap().as_bytes(), + &serde_json::to_string(&json_contents).unwrap().as_bytes(), )?; info!("[*] Creating \"searchindex.json\" ✓"); diff --git a/src/theme/book.js b/src/theme/book.js index d0a3c1db46..b219132888 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -17,6 +17,8 @@ $( document ).ready(function() { searchoptions : { bool: "AND", expand: true, + teaser_word_count : 30, + limit_results : 30, fields: { title: {boost: 1}, body: {boost: 1}, @@ -25,8 +27,6 @@ $( document ).ready(function() { }, mark_exclude : [], // ['.hljs'] current_searchterm : "", - teaser_words : 30, - resultcount_limit : 30, SEARCH_PARAM : 'search', MARK_PARAM : 'highlight', @@ -220,7 +220,7 @@ $( document ).ready(function() { } var window_weight = []; - var window_size = Math.min(weighted.length, this.teaser_words); + var window_size = Math.min(weighted.length, this.searchoptions.teaser_word_count); var cur_sum = 0; for (var wordindex = 0; wordindex < window_size; wordindex++) { @@ -280,8 +280,7 @@ $( document ).ready(function() { // Do the actual search var results = this.searchindex.search(searchterm, this.searchoptions); - var resultcount = (results.length > this.resultcount_limit) - ? this.resultcount_limit : results.length; + var resultcount = Math.min(results.length, this.searchoptions.limit_results); // Display search metrics this.searchresults_header.text(this.formatSearchMetric(resultcount, searchterm)); @@ -327,7 +326,14 @@ $( document ).ready(function() { //this.create_test_searchindex(); $.getJSON("searchindex.json", function(json) { - //this_.searchindex = elasticlunr.Index.load(json); + + if (json.enable == false) { + this_.searchicon.hide(); + return; + } + + this_.searchoptions = json.searchoptions; + //this_.searchindex = elasticlunr.Index.load(json.index); // TODO: Workaround: reindex everything var searchindex = elasticlunr(function () { @@ -337,7 +343,8 @@ $( document ).ready(function() { this.setRef('id'); }); window.mjs = json; - var docs = json.documentStore.docs; + window.search = this_; + var docs = json.index.documentStore.docs; for (var key in docs) { searchindex.addDoc(docs[key]); } diff --git a/src/theme/index.hbs b/src/theme/index.hbs index 99204f8805..b85914bb68 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -64,8 +64,6 @@ } - - diff --git a/src/utils/mod.rs b/src/utils/mod.rs index dfdb620fe4..2eeebfe82e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,6 +6,7 @@ use std::ascii::AsciiExt; use std::borrow::Cow; use std::fmt::Write; use regex::Regex; +use config::Search; /// A heading together with the successive content until the next heading will /// make up one `SearchDocument`. It represents some independently searchable part of the book. @@ -65,6 +66,7 @@ impl SearchDocument { /// The field `anchor` in the `SearchDocument` struct becomes /// `(anchor_base, Some(heading_to_anchor("The Section Heading")))` pub fn render_markdown_into_searchindex( + searchconfig: &Search, search_documents: &mut Vec, text: &str, anchor_base: &str, @@ -79,7 +81,7 @@ pub fn render_markdown_into_searchindex( let mut current = SearchDocument::new(&anchor_base, &hierarchy); let mut in_header = false; - let max_paragraph_level = 3; + let max_paragraph_level = searchconfig.split_until_heading as i32; let mut header_hierarchy = vec!["".to_owned(); max_paragraph_level as usize]; for event in p { From 26e16a83eb5166ffe3506ee157cde8020c252930 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Tue, 14 Nov 2017 21:57:06 +0100 Subject: [PATCH 10/14] Search: Result navigation with up/down --- src/theme/book.css | 17 +++++++++++++++++ src/theme/book.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/theme/book.css b/src/theme/book.css index e1b9d9ec23..c4d27620e4 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -220,6 +220,8 @@ ul#searchresults { } ul#searchresults li { margin: 10px 0px; + padding: 2px; + border-radius: 2px; } ul#searchresults .breadcrumbs { float: right; @@ -445,6 +447,9 @@ ul#searchresults span.teaser em { .light .searchresults-outer { border-bottom-color: #888; } +.light ul#searchresults li.focus { + background-color: #e4f2fe; +} .light .breadcrumbs { color: #CCC; } @@ -583,6 +588,9 @@ ul#searchresults span.teaser em { .coal .searchresults-outer { border-bottom-color: #98a3ad; } +.coal ul#searchresults li.focus { + background-color: #2b2b2f; +} .coal .breadcrumbs { color: #686868; } @@ -721,6 +729,9 @@ ul#searchresults span.teaser em { .navy .searchresults-outer { border-bottom-color: #5c5c68; } +.navy ul#searchresults li.focus { + background-color: #242430; +} .navy .breadcrumbs { color: #5c5c68; } @@ -859,6 +870,9 @@ ul#searchresults span.teaser em { .rust .searchresults-outer { border-bottom-color: #888; } +.rust ul#searchresults li.focus { + background-color: #dec2a2; +} .rust .breadcrumbs { color: #757575; } @@ -1001,6 +1015,9 @@ ul#searchresults span.teaser em { .ayu .searchresults-outer { border-bottom-color: #888; } +.ayu ul#searchresults li.focus { + background-color: #252932; +} .ayu .breadcrumbs { color: #5f5f5f; } diff --git a/src/theme/book.js b/src/theme/book.js index b219132888..5151bace21 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -32,6 +32,9 @@ $( document ).ready(function() { SEARCH_HOTKEY_KEYCODE: 83, ESCAPE_KEYCODE: 27, + DOWN_KEYCODE: 40, + UP_KEYCODE: 38, + SELECT_KEYCODE: 13, formatSearchMetric : function(count, searchterm) { if (count == 1) { @@ -371,6 +374,7 @@ $( document ).ready(function() { , globalKeyHandler : function (e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (e.keyCode == this.ESCAPE_KEYCODE) { e.preventDefault(); this.searchbar.removeClass("active"); @@ -386,6 +390,38 @@ $( document ).ready(function() { e.preventDefault(); this.searchbar_outer.slideDown() this.searchbar.focus(); + return; + } + if (this.hasFocus() && e.keyCode == this.DOWN_KEYCODE) { + e.preventDefault(); + this.unfocusSearchbar(); + this.searchresults.children('li').first().addClass("focus"); + return; + } + if (!this.hasFocus() && (e.keyCode == this.DOWN_KEYCODE + || e.keyCode == this.UP_KEYCODE + || e.keyCode == this.SELECT_KEYCODE)) { + // not `:focus` because browser does annoying scrolling + var current_focus = search.searchresults.find("li.focus"); + if (current_focus.length == 0) return; + e.preventDefault(); + if (e.keyCode == this.DOWN_KEYCODE) { + var next = current_focus.next() + if (next.length > 0) { + current_focus.removeClass("focus"); + next.addClass("focus"); + } + } else if (e.keyCode == this.UP_KEYCODE) { + current_focus.removeClass("focus"); + var prev = current_focus.prev(); + if (prev.length == 0) { + this.searchbar.focus(); + } else { + prev.addClass("focus"); + } + } else { + window.location = current_focus.children('a').attr('href'); + } } } , From 66ae6b6e928935ba623fefd4549dec21ddd7a8b4 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Tue, 21 Nov 2017 20:28:19 +0100 Subject: [PATCH 11/14] Search: Update elasticlunr-rs. Remove old code. Don't generate index if search is disabled --- Cargo.toml | 2 +- book-example/book.toml | 1 + src/renderer/html_handlebars/hbs_renderer.rs | 21 +++--- src/theme/book.js | 78 +------------------- src/utils/mod.rs | 4 + 5 files changed, 17 insertions(+), 89 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7d7f048d21..71b5431194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ toml = "0.4" open = "1.1" regex = "0.2.1" tempdir = "0.3.4" -elasticlunr = { git = "https://github.com/mattico/elasticlunr-rs" } +elasticlunr-rs = "0.2.1" # Watch feature notify = { version = "4.0", optional = true } diff --git a/book-example/book.toml b/book-example/book.toml index cfcb11bb2f..7a9a60c3b7 100644 --- a/book-example/book.toml +++ b/book-example/book.toml @@ -1,3 +1,4 @@ +[book] title = "mdBook Documentation" description = "Create book from markdown files. Like Gitbook but implemented in Rust" author = "Mathieu David" diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 9b6f01fcea..82d2d59057 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -312,7 +312,7 @@ impl Renderer for HtmlHandlebars { is_index = false; } - // Search index + // Search index (call this even if searching is disabled) make_searchindex(book, search_documents, &html_config.search)?; // Print version @@ -656,6 +656,8 @@ fn make_searchindex(book: &MDBook, search_documents : Vec, searchconfig : &Search) -> Result<()> { + // These structs mirror the configuration javascript object accepted by + // http://elasticlunr.com/docs/configuration.js.html #[derive(Serialize)] struct SearchOptionsField { @@ -669,7 +671,6 @@ fn make_searchindex(book: &MDBook, breadcrumbs: SearchOptionsField, } - /// The searchoptions for elasticlunr.js #[derive(Serialize)] struct SearchOptions { bool: String, @@ -681,11 +682,14 @@ fn make_searchindex(book: &MDBook, #[derive(Serialize)] struct SearchindexJson { + /// Propagate the search enabled/disabled setting to the html page enable: bool, #[serde(skip_serializing_if = "Option::is_none")] + /// The searchoptions for elasticlunr.js searchoptions: Option, + /// The index for elasticlunr.js #[serde(skip_serializing_if = "Option::is_none")] - index: Option, + index: Option, } @@ -703,22 +707,17 @@ fn make_searchindex(book: &MDBook, let json_contents = if searchconfig.enable { - let mut index = elasticlunr::index::Index::new("id", - &["title".into(), "body".into(), "breadcrumbs".into()]); + let mut index = elasticlunr::Index::new(&["title", "body", "breadcrumbs"]); for sd in search_documents { + // Concat the html link with the anchor ("abc.html#anchor") let anchor = if let Some(s) = sd.anchor.1 { format!("{}#{}", sd.anchor.0, &s) } else { sd.anchor.0 }; - let mut map = HashMap::new(); - map.insert("id".into(), anchor.clone()); - map.insert("title".into(), sd.title); - map.insert("body".into(), sd.body); - map.insert("breadcrumbs".into(), sd.hierarchy.join(" » ")); - index.add_doc(&anchor, map); + index.add_doc(&anchor, &[sd.title, sd.body, sd.hierarchy.join(" » ")]); } SearchindexJson { diff --git a/src/theme/book.js b/src/theme/book.js index 5151bace21..23bf2c8a24 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -46,63 +46,6 @@ $( document ).ready(function() { } } , - create_test_searchindex : function () { - var searchindex = elasticlunr(function () { - this.addField('body'); - this.addField('title'); - this.addField('breadcrumbs') - this.setRef('id'); - }); - var base_breadcrumbs = ""; - var active_chapter = $('.sidebar ul a.active'); - base_breadcrumbs = active_chapter.text().split('. ', 2)[1]; // demo - while (true) { - var parent_ul = active_chapter.parents('ul'); - if (parent_ul.length == 0) break; - var parent_li = parent_ul.parents('li'); - if (parent_li.length == 0) break; - var pre_li = parent_li.prev('li'); - if (pre_li.length == 0) break; - base_breadcrumbs = pre_li.text().split('. ', 2)[1] + ' » ' + base_breadcrumbs; - active_chapter = pre_li; - } - var paragraphs = this.content.children(); - var curr_title = ""; - var curr_body = ""; - var curr_ref = ""; - var push = function(ref) { - if ((curr_title.length > 0 || curr_body.length > 0) && curr_ref.length > 0) { - var doc = { - "id": curr_ref, - "body": curr_body, - "title": curr_title, - "breadcrumbs": base_breadcrumbs //"Header1 » Header2" - } - searchindex.addDoc(doc); - } - curr_body = ""; - curr_title = ""; - curr_ref = ""; - }; - paragraphs.each(function(index, element) { - // todo uppercase - var el = $(element); - if (el.prop('nodeName').toUpperCase() == "A") { - // new header, push old paragraph to index - push(index); - curr_title = el.text(); - curr_ref = el.attr('href'); - } else { - curr_body += " \n " + el.text(); - } - // last paragraph - if (index == paragraphs.length - 1) { - push(index); - } - }); - this.searchindex = searchindex; - } - , parseURL : function (url) { var a = document.createElement('a'); a.href = url; @@ -325,9 +268,6 @@ $( document ).ready(function() { init : function () { var this_ = this; - // For testing purposes: Index current page - //this.create_test_searchindex(); - $.getJSON("searchindex.json", function(json) { if (json.enable == false) { @@ -336,23 +276,7 @@ $( document ).ready(function() { } this_.searchoptions = json.searchoptions; - //this_.searchindex = elasticlunr.Index.load(json.index); - - // TODO: Workaround: reindex everything - var searchindex = elasticlunr(function () { - this.addField('body'); - this.addField('title'); - this.addField('breadcrumbs') - this.setRef('id'); - }); - window.mjs = json; - window.search = this_; - var docs = json.index.documentStore.docs; - for (var key in docs) { - searchindex.addDoc(docs[key]); - } - this_.searchindex = searchindex; - + this_.searchindex = elasticlunr.Index.load(json.index); // Set up events this_.searchicon.click( function(e) { this_.searchIconClickHandler(); } ); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2eeebfe82e..8cf67979cf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -74,6 +74,10 @@ pub fn render_markdown_into_searchindex( heading_to_anchor : F) where F : Fn(&str) -> String { + if ! searchconfig.enable { + return; + } + let mut opts = Options::empty(); opts.insert(OPTION_ENABLE_TABLES); opts.insert(OPTION_ENABLE_FOOTNOTES); From fa88440e7e36ac743b1311d7da1b2b04fd66f9ef Mon Sep 17 00:00:00 2001 From: Phaiax Date: Sun, 26 Nov 2017 19:05:36 +0100 Subject: [PATCH 12/14] Search: Fix scroll-to-top on escape (unmark). Rearrange javascript and add comments --- src/theme/book.js | 187 ++++++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 89 deletions(-) diff --git a/src/theme/book.js b/src/theme/book.js index 23bf2c8a24..0bfd7bb1ae 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -2,8 +2,8 @@ $( document ).ready(function() { // Search functionality // - // Usage: call init() on startup. You can use hasFocus() to disable prevent keyhandling - // while the user is typing his search. + // Usage: call init() on startup. You can use !hasFocus() to prevent keyhandling in your key + // event handlers while the user is typing his search. var search = { searchbar : $('#searchbar'), searchbar_outer : $('#searchbar-outer'), @@ -25,10 +25,10 @@ $( document ).ready(function() { breadcrumbs: {boost: 0} } }, - mark_exclude : [], // ['.hljs'] + mark_exclude : [], current_searchterm : "", - SEARCH_PARAM : 'search', - MARK_PARAM : 'highlight', + URL_SEARCH_PARAM : 'search', + URL_MARK_PARAM : 'highlight', SEARCH_HOTKEY_KEYCODE: 83, ESCAPE_KEYCODE: 27, @@ -36,16 +36,8 @@ $( document ).ready(function() { UP_KEYCODE: 38, SELECT_KEYCODE: 13, - formatSearchMetric : function(count, searchterm) { - if (count == 1) { - return count + " search result for '" + searchterm + "':"; - } else if (count == 0) { - return "No search results for '" + searchterm + "'."; - } else { - return count + " search results for '" + searchterm + "':"; - } - } - , + + // Helper to parse a url into its building blocks. parseURL : function (url) { var a = document.createElement('a'); a.href = url; @@ -71,6 +63,7 @@ $( document ).ready(function() { }; } , + // Helper to recreate a url string from its building blocks. renderURL : function (urlobject) { var url = urlobject.protocol + "://" + urlobject.host; if (urlobject.port != "") { @@ -90,6 +83,7 @@ $( document ).ready(function() { return url; } , + // Helper to escape html special chars for displaying the teasers escapeHTML: (function() { var MAP = { '&': '&', @@ -104,18 +98,27 @@ $( document ).ready(function() { }; })() , + formatSearchMetric : function(count, searchterm) { + if (count == 1) { + return count + " search result for '" + searchterm + "':"; + } else if (count == 0) { + return "No search results for '" + searchterm + "'."; + } else { + return count + " search results for '" + searchterm + "':"; + } + } + , formatSearchResult : function (result, searchterms) { - // Show text around first occurrence of first search term. var teaser = this.makeTeaser(this.escapeHTML(result.doc.body), searchterms); - // The ?MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor + // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor var url = result.ref.split("#"); - if (url.length == 1) { + if (url.length == 1) { // no anchor found url.push(""); } return $('
  • ' + result.doc.breadcrumbs + '' // doc.title + '' + '' + '' + teaser + '' @@ -216,55 +219,6 @@ $( document ).ready(function() { return teaser_split.join(''); } , - doSearch : function (searchterm) { - - // Don't search the same twice - if (this.current_searchterm == searchterm) { return; } - else { this.current_searchterm = searchterm; } - - if (this.searchindex == null) { return; } - - // Do the actual search - var results = this.searchindex.search(searchterm, this.searchoptions); - var resultcount = Math.min(results.length, this.searchoptions.limit_results); - - // Display search metrics - this.searchresults_header.text(this.formatSearchMetric(resultcount, searchterm)); - - // Clear and insert results - var searchterms = searchterm.split(' '); - this.searchresults.empty(); - for(var i = 0; i < resultcount ; i++){ - this.searchresults.append(this.formatSearchResult(results[i], searchterms)); - } - - // Display and scroll to results - this.searchresults_outer.slideDown(); - // this.searchicon.scrollTop(0); - } - , - doSearchOrMarkFromUrl : function () { - // Check current URL for search request - var url = this.parseURL(window.location.href); - if (url.params.hasOwnProperty(this.SEARCH_PARAM) - && url.params[this.SEARCH_PARAM] != "") { - this.searchbar_outer.slideDown(); - this.searchbar[0].value = decodeURIComponent( - (url.params[this.SEARCH_PARAM]+'').replace(/\+/g, '%20')); - this.searchbarKeyUpHandler(); - } else { - this.searchbar_outer.slideUp(); - } - - if (url.params.hasOwnProperty(this.MARK_PARAM)) { - var words = url.params[this.MARK_PARAM].split(' '); - var header = $('#' + url.hash); - this.content.mark(words, { - exclude : this.mark_exclude - }); - } - } - , init : function () { var this_ = this; @@ -296,6 +250,38 @@ $( document ).ready(function() { return this.searchbar.is(':focus'); } , + unfocusSearchbar : function () { + // hacky, but just focusing a div only works once + var tmp = $(''); + tmp.insertAfter(this.searchicon); + tmp.focus(); + tmp.remove(); + } + , + // On reload or browser history backwards/forwards events, parse the url and do search or mark + doSearchOrMarkFromUrl : function () { + // Check current URL for search request + var url = this.parseURL(window.location.href); + if (url.params.hasOwnProperty(this.URL_SEARCH_PARAM) + && url.params[this.URL_SEARCH_PARAM] != "") { + this.searchbar_outer.slideDown(); + this.searchbar[0].value = decodeURIComponent( + (url.params[this.URL_SEARCH_PARAM]+'').replace(/\+/g, '%20')); + this.searchbarKeyUpHandler(); // -> doSearch() + } else { + this.searchbar_outer.slideUp(); + } + + if (url.params.hasOwnProperty(this.URL_MARK_PARAM)) { + var words = url.params[this.URL_MARK_PARAM].split(' '); + var header = $('#' + url.hash); + this.content.mark(words, { + exclude : this.mark_exclude + }); + } + } + , + // Eventhandler for keyevents on `document` globalKeyHandler : function (e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } @@ -304,8 +290,10 @@ $( document ).ready(function() { this.searchbar.removeClass("active"); // this.searchbar[0].value = ""; this.setSearchUrlParameters("", - (this.searchbar[0].value.trim() != 0) ? "push" : "replace"); - this.unfocusSearchbar(); + (this.searchbar[0].value.trim() != "") ? "push" : "replace"); + if (this.hasFocus()) { + this.unfocusSearchbar(); + } this.searchbar_outer.slideUp(); this.content.unmark(); return; @@ -349,21 +337,13 @@ $( document ).ready(function() { } } , - unfocusSearchbar : function () { - // hacky, but just focusing a div only works once - var tmp = $(''); - tmp.insertAfter(this.searchicon); - tmp.focus(); - tmp.remove(); - } - , + // Eventhandler for search icon searchIconClickHandler : function () { this.searchbar_outer.slideToggle(); this.searchbar.focus(); - // TODO: - // If invisible, clear URL search parameter } , + // Eventhandler for keyevents while the searchbar is focused searchbarKeyUpHandler : function () { var searchterm = this.searchbar[0].value.trim(); if (searchterm != "") { @@ -375,32 +355,61 @@ $( document ).ready(function() { this.searchresults.empty(); } - this.setSearchUrlParameters(searchterm, "if_begin_search"); + this.setSearchUrlParameters(searchterm, "push_if_new_search_else_replace"); // Remove marks this.content.unmark(); } , + // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor . + // `action` can be one of "push", "replace", "push_if_new_search_else_replace" + // and replaces or pushes a new browser history item. + // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet. setSearchUrlParameters : function(searchterm, action) { - // Update url with ?SEARCH_PARAM= parameter, remove ?MARK_PARAM and #heading-anchor var url = this.parseURL(window.location.href); - var first_search = ! url.params.hasOwnProperty(this.SEARCH_PARAM); - if (searchterm != "" || action == "if_begin_search") { - url.params[this.SEARCH_PARAM] = searchterm; - delete url.params[this.MARK_PARAM]; + var first_search = ! url.params.hasOwnProperty(this.URL_SEARCH_PARAM); + if (searchterm != "" || action == "push_if_new_search_else_replace") { + url.params[this.URL_SEARCH_PARAM] = searchterm; + delete url.params[this.URL_MARK_PARAM]; url.hash = ""; } else { - delete url.params[this.SEARCH_PARAM]; + delete url.params[this.URL_SEARCH_PARAM]; } // A new search will also add a new history item, so the user can go back // to the page prior to searching. A updated search term will only replace // the url. - if (action == "push" || (action == "if_begin_search" && first_search) ) { + if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) { history.pushState({}, document.title, this.renderURL(url)); - } else if (action == "replace" || (action == "if_begin_search" && !first_search) ) { + } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) { history.replaceState({}, document.title, this.renderURL(url)); } + } + , + doSearch : function (searchterm) { + // Don't search the same twice + if (this.current_searchterm == searchterm) { return; } + else { this.current_searchterm = searchterm; } + + if (this.searchindex == null) { return; } + + // Do the actual search + var results = this.searchindex.search(searchterm, this.searchoptions); + var resultcount = Math.min(results.length, this.searchoptions.limit_results); + + // Display search metrics + this.searchresults_header.text(this.formatSearchMetric(resultcount, searchterm)); + + // Clear and insert results + var searchterms = searchterm.split(' '); + this.searchresults.empty(); + for(var i = 0; i < resultcount ; i++){ + this.searchresults.append(this.formatSearchResult(results[i], searchterms)); + } + + // Display and scroll to results + this.searchresults_outer.slideDown(); + // this.searchicon.scrollTop(0); } }; From 0ad8535084c35bc96a70c824d99093044892a942 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Sun, 26 Nov 2017 19:41:39 +0100 Subject: [PATCH 13/14] Search: Add config documentation. Add to contributors. --- book-example/src/format/config.md | 39 ++++++++++++++++++++++++--- book-example/src/misc/contributors.md | 1 + 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 8b7bdd0005..97899b7cfb 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -13,6 +13,10 @@ description = "The example book covers examples." [output.html] destination = "my-example-book" additional-css = ["custom.css"] + +[output.html.search] +enable = true +limit-results = 15 ``` ## Supported configuration options @@ -51,8 +55,6 @@ renderer need to be specified under the TOML table `[output.html]`. The following configuration options are available: - pub playpen: Playpen, - - **theme:** mdBook comes with a default theme and all the resource files needed for it. But if this option is set, mdBook will selectively overwrite the theme files with the ones found in the specified folder. @@ -68,13 +70,33 @@ The following configuration options are available: removing the current behaviour, you can specify a set of javascript files that will be loaded alongside the default one. - **playpen:** A subtable for configuring various playpen settings. +- **search:** A subtable for configuring the browser based search functionality. -**book.toml** +Available configuration options for the `[output.html.search]` table: + +- **enable:** Enable or disable the search function. Disabling can improve compilation time by a factor of two. Defaults to `true`. +- **limit-results:** The maximum number of search results. Defaults to `30`. +- **teaser-word-count:** The number of words used for a search result teaser. Defaults to `30`. +- **use-boolean-and:** Define the logical link between multiple search words. If true, all search words must appear in each result. Defaults to `true`. +- **boost-title:** Boost factor for the search result score if a search word appears in the header. Defaults to `2`. +- **boost-hierarchy:** Boost factor for the search result score if a search word appears in the hierarchy. The hierarchy contains all titles of the parent documents and all parent headings. Defaults to `1`. +- **boost-paragraph:** Boost factor for the search result score if a search word appears in the text. Defaults to `1`. +- **expand:** True if the searchword `micro` should match `microwave`. Defaults to `true`. +- **split-until-heading:** Documents are split into smaller parts, seperated by headings. This defines, until which level of heading documents should be split. Defaults to `3`. (`### This is a level 3 heading`) + +Available configuration options for the `[output.html.playpen]` table: + +- **editor:** Source folder for the editors javascript files. Defaults to `""`. +- **editable:** Allow editing the source code. Defaults to `false`. + +This shows all available options in the **book.toml**: ```toml [book] title = "Example book" authors = ["John Doe", "Jane Doe"] description = "The example book covers examples." +src = "my-src" # the source files will be found in `root/my-src` instead of `root/src` +build-dir = "build" [output.html] theme = "my-theme" @@ -83,6 +105,17 @@ google-analytics = "123456" additional-css = ["custom.css", "custom2.css"] additional-js = ["custom.js"] +[output.html.search] +enable = true +limit-results = 30 +teaser-word-count = 30 +use-boolean-and = true +boost-title = 2 +boost-hierarchy = 1 +boost-paragraph = 1 +expand = true +split-until-heading = 3 + [output.html.playpen] editor = "./path/to/editor" editable = false diff --git a/book-example/src/misc/contributors.md b/book-example/src/misc/contributors.md index de01338e27..031236c1ae 100644 --- a/book-example/src/misc/contributors.md +++ b/book-example/src/misc/contributors.md @@ -12,3 +12,4 @@ If you have contributed to mdBook and I forgot to add you, don't hesitate to add - [funnkill](https://github.com/funkill) - Fu Gangqiang ([FuGangqiang](https://github.com/FuGangqiang)) - [Michael-F-Bryan](https://github.com/Michael-F-Bryan) +- [Phaiax](https://github.com/Phaiax) \ No newline at end of file From 592fb17c705c617e511512ab09ce181193941933 Mon Sep 17 00:00:00 2001 From: Phaiax Date: Sun, 26 Nov 2017 19:52:59 +0100 Subject: [PATCH 14/14] Search: Add handlebars variable and do not load js libraries if search is disabled. --- src/renderer/html_handlebars/hbs_renderer.rs | 2 ++ src/theme/index.hbs | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 82d2d59057..04d81aaf3e 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -413,6 +413,8 @@ fn make_data(book: &MDBook, config: &Config) -> Result + {{#if search}} + {{/if}} @@ -94,7 +96,9 @@
    - + {{#if search}} + + {{/if}}

    {{ book_title }}

    @@ -106,6 +110,7 @@ + {{#if search}}
    @@ -115,6 +120,7 @@
    + {{/if}}