diff --git a/make-example-search-worker.js b/make-example-search-worker.js new file mode 100644 index 00000000..4cddd6e4 --- /dev/null +++ b/make-example-search-worker.js @@ -0,0 +1,45 @@ +var fs = require("fs"); +var path = require("path"); +var stealTools = require("steal-tools"); + +var forceBuild = process.argv.indexOf("-f") !== -1; + +var siteConfig = { + debug: false, + dest: path.join(__dirname, 'doc', 'workers'), + minifyBuild: true +}; + +var dest = siteConfig.dest + '/static/search-worker.js'; + +fs.mkdir(siteConfig.dest, function() { + stealTools.export({ + system: { + config: __dirname + '/package.json!npm', + main: 'static/search-worker' + }, + options: { + bundleAssets: true, + bundleSteal: true, + debug: siteConfig.debug ? true : false, + minify: siteConfig.minifyBuild === false ? false : true, + quiet: siteConfig.debug ? false : true + }, + outputs: { + "+standalone" : { + dest: dest + } + } + }).then(function(){ + // Work around for https://github.com/stealjs/steal-tools/issues/775 + console.info('Replacing "window" with "self"'); + fs.readFile(dest, 'utf8', function(err, file){ + if(err) return console.error(err); + file = file.replace(/window/gmi, 'self'); + fs.writeFile(dest, file, function(err){ + if(err) return console.error(err); + console.info('Done'); + }); + }); + }); +}); diff --git a/package.json b/package.json index 2706ec58..890c83f3 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "bit-docs-html-canjs", - "version": "1.0.7", + "version": "1.0.8", "description": "The plugins to produce the CanJS site", "main": "static/canjs", "scripts": { - "start": "node make-example.js -f", + "start": "node make-example-search-worker.js && node make-example.js -f", "styles": "rm -rf node_modules/bit-docs-generate-html/site/static && npm start", "test": "npm start && npm run testee", "testee": "testee test/browser.html --browsers firefox", @@ -35,7 +35,7 @@ "escape-html": "^1.0.3", "jquery": "^3.1.1", "lodash": "^4.17.4", - "lunr": "^2.1.0", + "lunr": "bit-docs/lunr.js#279-safari-exception", "steal-stache": "^3.0.1", "unescape-html": "^1.0.0" }, diff --git a/static/loading-bar.js b/static/loading-bar.js index 0c9a7dc5..73293c7e 100644 --- a/static/loading-bar.js +++ b/static/loading-bar.js @@ -1,23 +1,27 @@ -function LoadingBar(c){ - this.meter = $('', {style: 'width:0%;'}); - this.elem = $('
', {style: 'display:none', class: 'meter animate '+c}).append( - this.meter.append($('')) +function LoadingBar(color, parent) { + this.meter = $('', {style: 'transition: width 1s ease; width:0%;'}); + this.elem = $('
', {style: 'display:none', class: 'meter animate ' + color}).append( + this.meter.append($('')) ); - $('body').prepend(this.elem); - return this; + + parent = parent || $('body'); + parent.prepend(this.elem); + + return this; } -LoadingBar.prototype.start = function(){ - this.meter.css('width', '1%'); - this.elem.show(); +LoadingBar.prototype.start = function(percentage) { + percentage = percentage || 0; + this.meter.css('width', percentage + '%'); + this.elem.show(); }; -LoadingBar.prototype.end = function(){ - this.elem.hide(); +LoadingBar.prototype.end = function() { + this.elem.hide(); }; -LoadingBar.prototype.update = function(p){ - this.meter.css('width', p+'%'); +LoadingBar.prototype.update = function(percentage) { + this.meter.css('width', percentage + '%'); }; -module.exports = LoadingBar; \ No newline at end of file +module.exports = LoadingBar; diff --git a/static/search-logic.js b/static/search-logic.js new file mode 100644 index 00000000..6df5e3cc --- /dev/null +++ b/static/search-logic.js @@ -0,0 +1,63 @@ +var lunr = require("lunr"); +var searchEngine; + +module.exports = { + indexData: function(items) { + searchEngine = lunr(function() { + lunr.tokenizer.separator = /[\s]+/; + + this.pipeline.remove(lunr.stemmer); + this.pipeline.remove(lunr.stopWordFilter); + this.pipeline.remove(lunr.trimmer); + this.searchPipeline.remove(lunr.stemmer); + + this.ref('name'); + this.field('title'); + this.field('description'); + this.field('name'); + this.field('url'); + + items.forEach(function(item) { + if (!item.title) { + item.title = item.name; + } + this.add(item); + }.bind(this)); + }); + return searchEngine; + }, + + loadIndex: function(index) { + return lunr.Index.load(index); + }, + + search: function(value) { + var searchTerm = value.toLowerCase(); + + //run the search + return searchEngine.query(function(q) { + + if (searchTerm.indexOf('can-') > -1) {// If the search term includes “can-” + + // look for an exact match and apply a large positive boost + q.term(searchTerm, { boost: 375 }); + + } else { + // add “can-”, look for an exact match in the title field, and apply a positive boost + q.term('can-' + searchTerm, { boost: 12 }); + + // look for terms that match the beginning or end of this query + // look in the title field specifically to boost matches in it + q.term(searchTerm, { fields: ['title'], wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING }); + q.term(searchTerm, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING }); + } + + // look for matches in any of the fields and apply a medium positive boost + var split = searchTerm.split(lunr.tokenizer.separator); + split.forEach(function(term) { + q.term(term, { boost: 10, fields: q.allFields }); + q.term(term, { fields: q.allFields, wildcard: lunr.Query.wildcard.TRAILING }); + }); + }); + } +}; diff --git a/static/search-worker.js b/static/search-worker.js new file mode 100644 index 00000000..1df82f83 --- /dev/null +++ b/static/search-worker.js @@ -0,0 +1,33 @@ +var logic = require('static/search-logic'); +var searchEngine; + +self.addEventListener('message', function(message) { + var data = message.data; + + switch (data.name) { + case 'index data': + self.postMessage({ + name: 'search engine ready', + searchEngine: logic.indexData(data.items) + }); + break; + + case 'load index': + self.postMessage({ + name: 'search engine ready', + searchEngine: logic(data.index) + }); + break; + + case 'search': + self.postMessage({ + name: 'search results', + results: logic.search(data.query), + query: data.query + }); + break; + + default: + console.info('Worker received message:', message); + } +}, false); diff --git a/static/search.js b/static/search.js index c480da72..032d880f 100644 --- a/static/search.js +++ b/static/search.js @@ -1,10 +1,9 @@ var $ = require("jquery"); var Control = require("can-control"); +var LoadingBar = require('./loading-bar'); var searchResultsRenderer = require("../templates/search-results.stache!steal-stache"); var joinURIs = require("can-util/js/join-uris/"); - -//https://lunrjs.com/guides/getting_started.html -var lunr = require("lunr"); +var currentIndexVersion = 4;// Bump this whenever the index code is changed var Search = Control.extend({ @@ -68,9 +67,6 @@ var Search = Control.extend({ init: function(){ - var options = this.options; - var self = this; - //init elements this.setElements(); @@ -79,20 +75,25 @@ var Search = Control.extend({ this.useLocalStorage = this.localStorageIsAvailable(); + if (window.Worker) { + this.initSearchWorker(); + } else { + console.info('window.Worker not defined, so not enabling search features'); + } + }, + initSearchWorker: function() { + var options = this.options; + var self = this; + var workerPath = options.pathPrefix + '/workers/static/search-worker.js'; + + this.searchWorker = new Worker(workerPath); + this.searchWorker.addEventListener('message', this.didReceiveWorkerMessage.bind(this)); + this.searchEnginePromise = new Promise(function(resolve, reject) { self.checkSearchMapHash(options.pathPrefix + options.searchMapHashUrl).then(function(searchMapHashChangedObject){ self.getSearchMap(options.pathPrefix + options.searchMapUrl, searchMapHashChangedObject).then(function(searchMap){ - var searchEngine = self.initSearchEngine(searchMap); - resolve(searchEngine); - - //show the search input when the search engine is ready - if(self.options.animateInOnStart){ - self.$inputWrap.fadeIn(self.options.searchAnimation); - }else{ - self.$inputWrap.show(); - } - - self.bindResultsEvents(); + self.initSearchEngine(searchMap); + resolve(searchMap); }, function(error){ console.error("getSearchMap error", error); reject(error); @@ -103,6 +104,42 @@ var Search = Control.extend({ }); }); }, + didReceiveWorkerMessage: function(message) { + var data = message.data; + switch (data.name) { + case 'search did index': + var searchIndexKey = this.formatLocalStorageKey(this.searchIndexLocalStorageKey); + var searchIndexVersionKey = this.formatLocalStorageKey(this.searchIndexVersionLocalStorageKey); + this.setLocalStorageItem(searchIndexKey, data.searchEngine); + this.setLocalStorageItem(searchIndexVersionKey, currentIndexVersion); + break; + + case 'search engine ready': + //show the search input when the search engine is ready + if(this.options.animateInOnStart){ + this.$inputWrap.fadeIn(this.options.searchAnimation); + }else{ + this.$inputWrap.show(); + } + + this.bindResultsEvents(); + break; + + case 'search results': + //convert the results into a searchMap subset + var searchMap = this.searchMap; + var results = data.results.map(function(result) { + return searchMap[result.ref]; + }); + this.searchResultsCache = results; + this.searchIndicator.end(); + this.renderSearchResults(results); + break; + + default: + console.info('Received message from worker:', message); + } + }, destroy: function(){ this.unbindResultsEvents(); this.unsetElements(); @@ -315,77 +352,47 @@ var Search = Control.extend({ var searchIndexVersionKey = this.formatLocalStorageKey(this.searchIndexVersionLocalStorageKey); var index = this.getLocalStorageItem(searchIndexKey); var indexVersion = this.getLocalStorageItem(searchIndexVersionKey); - var currentIndexVersion = 3;// Bump this whenever the index code is changed if (index && currentIndexVersion === indexVersion) { - searchEngine = lunr.Index.load(index); - }else{ - searchEngine = lunr(function(){ - lunr.tokenizer.separator = /[\s]+/; - - this.pipeline.remove(lunr.stemmer); - this.pipeline.remove(lunr.stopWordFilter); - this.pipeline.remove(lunr.trimmer); - this.searchPipeline.remove(lunr.stemmer); - - this.ref('name'); - this.field('title'); - this.field('description'); - this.field('name'); - this.field('url'); - - for (var itemKey in searchMap) { - if (searchMap.hasOwnProperty(itemKey)) { - var item = searchMap[itemKey]; - if(!item.title){ - item.title = item.name; - } - this.add(item); - } - } + this.searchWorker.postMessage({ + name: 'load index', + index: index + }); + + } else { + this.searchWorker.postMessage({ + name: 'index data', + index: index, + items: this.convertSearchMapToIndexableItems(searchMap) }); - this.setLocalStorageItem(searchIndexKey, searchEngine); - this.setLocalStorageItem(searchIndexVersionKey, currentIndexVersion); } - return searchEngine; }, - // function searchEngineSearch - // takes a value and returns a map of all relevant search items - searchEngineSearch: function(value){ - var searchTerm = value.toLowerCase(); - var self = this; - return this.searchEnginePromise.then(function(searchEngine) { - - //run the search - var queryResults = searchEngine.query(function(q) { + convertSearchMapToIndexableItems: function(searchMap) { + var dummyContainer = document.createElement('div'); + var items = []; - if (searchTerm.indexOf('can-') > -1) {// If the search term includes “can-” + for (var itemKey in searchMap) { + if (searchMap.hasOwnProperty(itemKey)) { + var item = searchMap[itemKey]; - // look for an exact match and apply a large positive boost - q.term(searchTerm, { boost: 375 }); + // Convert HTML to text + dummyContainer.innerHTML = item.description; + item.description = dummyContainer.innerText; - } else { - // add “can-”, look for an exact match in the title field, and apply a positive boost - q.term('can-' + searchTerm, { boost: 12 }); - - // look for terms that match the beginning or end of this query - // look in the title field specifically to boost matches in it - q.term(searchTerm, { fields: ['title'], wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING }); - q.term(searchTerm, { wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING }); - } - - // look for matches in any of the fields and apply a medium positive boost - var split = searchTerm.split(lunr.tokenizer.separator); - split.forEach(function(term) { - q.term(term, { boost: 10, fields: q.allFields }); - }); - }); + items.push(item); + } + } - //convert the results into a searchMap subset - var mappedResults = queryResults.map(function(result){ return self.searchMap[result.ref] }); + return items; + }, - return mappedResults; + // function searchEngineSearch + // takes a value and returns a map of all relevant search items + searchEngineSearch: function(value) { + this.searchWorker.postMessage({ + name: 'search', + query: value }); }, // ---- END SEARCHING / PARSING ---- // @@ -488,10 +495,12 @@ var Search = Control.extend({ clearTimeout(this.searchDebounceHandle); var self = this; this.searchDebounceHandle = setTimeout(function(){ - self.searchEngineSearch(value).then(function(results) { - self.searchResultsCache = results; - self.renderSearchResults(results); - }); + if (!self.searchIndicator) { + self.searchIndicator = new LoadingBar('blue', self.$resultsContainer); + } + self.searchIndicator.start(0); + self.searchIndicator.update(100); + self.searchEngineSearch(value); }, this.options.searchTimeout); }, diff --git a/test/search.js b/test/search.js index 795fc3ab..956253ab 100644 --- a/test/search.js +++ b/test/search.js @@ -1,10 +1,11 @@ var QUnit = require('steal-qunit'); var SearchControl = require('../static/search'); +var searchLogic = require('../static/search-logic'); /* Helper function for finding a specific result */ var indexOfPageInResults = function(pageName, results) { return results.findIndex(function(result) { - return result.name === pageName; + return result.ref === pageName; }); }; @@ -24,10 +25,17 @@ var search = new SearchControl('#search-bar', { /* Tests */ QUnit.module('search control'); +var readyToSearch = function() { + return search.searchEnginePromise.then(function(searchMap) { + searchLogic.indexData(search.convertSearchMapToIndexableItems(searchMap)); + }); +}; + QUnit.test('Search for “about”', function(assert) { var done = assert.async(); - search.searchEngineSearch('about').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('about'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('about', results), 0, 'first result is the About page'); done(); }); @@ -35,8 +43,9 @@ QUnit.test('Search for “about”', function(assert) { QUnit.test('Search for “can-component”', function(assert) { var done = assert.async(); - search.searchEngineSearch('can-component').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('can-component'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('can-component', results), 0, 'first result is the can-component page'); done(); }); @@ -44,8 +53,9 @@ QUnit.test('Search for “can-component”', function(assert) { QUnit.test('Search for “can-connect”', function(assert) { var done = assert.async(); - search.searchEngineSearch('can-connect').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('can-connect'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('can-connect', results), 0, 'first result is the can-connect page'); done(); }); @@ -53,16 +63,18 @@ QUnit.test('Search for “can-connect”', function(assert) { QUnit.test('Search for “helpers/', function(assert) { var done = assert.async(); - search.searchEngineSearch('helpers/').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('helpers/'); + assert.equal(results.length > 1, true, 'got more than 1 result'); done(); }); }); QUnit.test('Search for “Live Binding”', function(assert) { var done = assert.async(); - search.searchEngineSearch('Live Binding').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('Live Binding'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('can-stache.Binding', results) < 2, true, 'first result is the can-stache Live Binding page'); done(); }); @@ -70,7 +82,8 @@ QUnit.test('Search for “Live Binding”', function(assert) { QUnit.test('Search for “Play”', function(assert) { var done = assert.async(); - search.searchEngineSearch('Play').then(function(results) { + readyToSearch().then(function() { + var results = searchLogic.search('Play'); assert.equal(results.length > 0, true, 'got results'); assert.equal(indexOfPageInResults('guides/recipes/playlist-editor', results), 0, 'first result is the “Playlist Editor (Advanced)” guide'); done(); @@ -79,8 +92,9 @@ QUnit.test('Search for “Play”', function(assert) { QUnit.test('Search for “stache”', function(assert) { var done = assert.async(); - search.searchEngineSearch('stache').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('stache'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('can-stache', results), 0, 'first result is the can-stache page'); done(); }); @@ -88,7 +102,8 @@ QUnit.test('Search for “stache”', function(assert) { QUnit.test('Search for “%special”', function(assert) { var done = assert.async(); - search.searchEngineSearch('%special').then(function(results) { + readyToSearch().then(function() { + var results = searchLogic.search('%special'); assert.equal(results.length > 0, true, 'got results'); assert.equal(indexOfPageInResults('can-stache/keys/special', results), 0, 'first result is the can-stache/keys/special page'); done(); @@ -97,8 +112,9 @@ QUnit.test('Search for “%special”', function(assert) { QUnit.test('Search for “define/map”', function(assert) { var done = assert.async(); - search.searchEngineSearch('define/map').then(function(results) { - assert.equal(results.length > 0, true, 'got results'); + readyToSearch().then(function() { + var results = searchLogic.search('define/map'); + assert.equal(results.length > 1, true, 'got more than 1 result'); assert.equal(indexOfPageInResults('can-define/map/map', results), 0, 'first result is the can-define/map/map page'); done(); }); @@ -107,8 +123,9 @@ QUnit.test('Search for “define/map”', function(assert) { QUnit.test('Speed while searching for can-*', function(assert) { var done = assert.async(); - var startTime = new Date(); - search.searchEngineSearch('can-zone').then(function() { + readyToSearch().then(function() { + var startTime = new Date(); + var results = searchLogic.search('can-zone'); var totalTime = new Date() - startTime; assert.equal(totalTime < 300, true, 'less than 300 milliseconds'); done();