diff --git a/aio/angular.json b/aio/angular.json index 56c2927740..acb1c563c7 100644 --- a/aio/angular.json +++ b/aio/angular.json @@ -21,6 +21,7 @@ "index": "src/index.html", "main": "src/main.ts", "tsConfig": "src/tsconfig.app.json", + "webWorkerTsConfig": "src/tsconfig.worker.json", "aot": true, "optimization": true, "buildOptimizer": true, @@ -35,7 +36,6 @@ "assets": [ "src/assets", "src/generated", - "src/app/search/search-worker.js", "src/pwa-manifest.json", "src/google385281288605d160.html", { @@ -123,6 +123,7 @@ "karmaConfig": "src/karma.conf.js", "polyfills": "src/polyfills.ts", "tsConfig": "src/tsconfig.spec.json", + "webWorkerTsConfig": "src/tsconfig.worker.json", "scripts": [], "styles": [ "src/styles.scss" @@ -130,7 +131,6 @@ "assets": [ "src/assets", "src/generated", - "src/app/search/search-worker.js", "src/pwa-manifest.json", "src/google385281288605d160.html", { diff --git a/aio/ngsw-config.json b/aio/ngsw-config.json index 95386ea0a5..2178ba8815 100644 --- a/aio/ngsw-config.json +++ b/aio/ngsw-config.json @@ -9,10 +9,8 @@ "files": [ "/index.html", "/pwa-manifest.json", - "/app/search/search-worker.js", "/assets/images/favicons/favicon.ico", "/assets/js/*.js", - "/generated/lunr.min.js", "/*.css", "/*.js" ], diff --git a/aio/package.json b/aio/package.json index 589b08df4d..c6529a06b6 100644 --- a/aio/package.json +++ b/aio/package.json @@ -26,7 +26,7 @@ "e2e": "ng e2e --no-webdriver-update", "presetup": "yarn --cwd .. install && yarn install --frozen-lockfile && yarn ~~check-env && yarn ~~clean-generated && yarn boilerplate:remove", "setup": "yarn aio-use-npm && yarn example-use-npm", - "postsetup": "yarn ~~build-ie-polyfills && yarn ~~minify-lunr && yarn boilerplate:add && yarn extract-cli-command-docs && yarn docs", + "postsetup": "yarn ~~build-ie-polyfills && yarn boilerplate:add && yarn extract-cli-command-docs && yarn docs", "presetup-local": "yarn presetup", "setup-local": "yarn aio-use-local && yarn example-use-local", "postsetup-local": "yarn postsetup", @@ -69,8 +69,7 @@ "~~build": "ng build --configuration=stable", "post~~build": "yarn build-404-page", "~~build-ie-polyfills": "webpack-cli src/ie-polyfills.js -o src/generated/ie-polyfills.min.js --mode production", - "~~http-server": "http-server", - "~~minify-lunr": "uglifyjs node_modules/lunr/lunr.js -c -m -o src/generated/lunr.min.js --source-map" + "~~http-server": "http-server" }, "engines": { "node": ">=10.9.0 <11.0.0", @@ -89,6 +88,7 @@ "@angular/platform-browser-dynamic": "^7.0.0", "@angular/router": "^7.0.0", "@angular/service-worker": "^7.0.0", + "@types/lunr": "^2.3.2", "@webcomponents/custom-elements": "^1.2.0", "classlist.js": "^1.1.20150312", "core-js": "^2.4.1", diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 1fad31d792..a7ccb03b0a 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -110,7 +110,7 @@ export class AppComponent implements OnInit { // Do not initialize the search on browsers that lack web worker support if ('Worker' in window) { // Delay initialization by up to 2 seconds - this.searchService.initWorker('app/search/search-worker.js', 2000); + this.searchService.initWorker(2000); } this.onResize(window.innerWidth); diff --git a/aio/src/app/search/search-worker.js b/aio/src/app/search/search-worker.js deleted file mode 100644 index c47dabd82d..0000000000 --- a/aio/src/app/search/search-worker.js +++ /dev/null @@ -1,106 +0,0 @@ -'use strict'; - -/* eslint-env worker */ -/* global importScripts, lunr */ - -var SEARCH_TERMS_URL = '/generated/docs/app/search-data.json'; - -// NOTE: This needs to be kept in sync with `ngsw-config.json`. -importScripts('/generated/lunr.min.js'); - -var index; -var pages /* : SearchInfo */ = {}; - -// interface SearchInfo { -// [key: string]: PageInfo; -// } - -// interface PageInfo { -// path: string; -// type: string, -// titleWords: string; -// keyWords: string; -// } - -self.onmessage = handleMessage; - -// Create the lunr index - the docs should be an array of objects, each object containing -// the path and search terms for a page -function createIndex(addFn) { - lunr.QueryLexer.termSeparator = lunr.tokenizer.separator = /\s+/; - return lunr(/** @this */function() { - this.ref('path'); - this.field('titleWords', {boost: 10}); - this.field('headingWords', {boost: 5}); - this.field('members', {boost: 4}); - this.field('keywords', {boost: 2}); - addFn(this); - }); -} - -// The worker receives a message to load the index and to query the index -function handleMessage(message) { - var type = message.data.type; - var id = message.data.id; - var payload = message.data.payload; - switch(type) { - case 'load-index': - makeRequest(SEARCH_TERMS_URL, function(searchInfo) { - index = createIndex(loadIndex(searchInfo)); - self.postMessage({type: type, id: id, payload: true}); - }); - break; - case 'query-index': - self.postMessage({type: type, id: id, payload: {query: payload, results: queryIndex(payload)}}); - break; - default: - self.postMessage({type: type, id: id, payload: {error: 'invalid message type'}}) - } -} - -// Use XHR to make a request to the server -function makeRequest(url, callback) { - - // The JSON file that is loaded should be an array of PageInfo: - var searchDataRequest = new XMLHttpRequest(); - searchDataRequest.onload = function() { - callback(JSON.parse(this.responseText)); - }; - searchDataRequest.open('GET', url); - searchDataRequest.send(); -} - - -// Create the search index from the searchInfo which contains the information about each page to be indexed -function loadIndex(searchInfo /*: SearchInfo */) { - return function(index) { - // Store the pages data to be used in mapping query results back to pages - // Add search terms from each page to the search index - searchInfo.forEach(function(page /*: PageInfo */) { - index.add(page); - pages[page.path] = page; - }); - }; -} - -// Query the index and return the processed results -function queryIndex(query) { - try { - if (query.length) { - var results = index.search(query); - if (results.length === 0) { - // Add a relaxed search in the title for the first word in the query - // E.g. if the search is "ngCont guide" then we search for "ngCont guide titleWords:ngCont*" - var titleQuery = 'titleWords:*' + query.split(' ', 1)[0] + '*'; - results = index.search(query + ' ' + titleQuery); - } - // Map the hits into info about each page to be returned as results - return results.map(function(hit) { return pages[hit.ref]; }); - } - } catch(e) { - // If the search query cannot be parsed the index throws an error - // Log it and recover - console.log(e); - } - return []; -} diff --git a/aio/src/app/search/search-worker.ts b/aio/src/app/search/search-worker.ts new file mode 100644 index 0000000000..655c435174 --- /dev/null +++ b/aio/src/app/search/search-worker.ts @@ -0,0 +1,104 @@ +import { WebWorkerMessage } from '../shared/web-worker-message'; +import * as lunr from 'lunr'; + +const SEARCH_TERMS_URL = '/generated/docs/app/search-data.json'; +let index: lunr.Index; +const pages: SearchInfo = {}; + +interface SearchInfo { + [key: string]: PageInfo; +} + +interface PageInfo { + path: string; + type: string; + titleWords: string; + keyWords: string; +} + +addEventListener('message', handleMessage); + +// Create the lunr index - the docs should be an array of objects, each object containing +// the path and search terms for a page +function createIndex(loadIndex: IndexLoader): lunr.Index { + // The lunr typings are missing QueryLexer so we have to add them here manually. + const queryLexer = (lunr as any as { QueryLexer: { termSeparator: RegExp } }).QueryLexer; + queryLexer.termSeparator = lunr.tokenizer.separator = /\s+/; + return lunr(/** @this */function () { + this.ref('path'); + this.field('titleWords', { boost: 10 }); + this.field('headingWords', { boost: 5 }); + this.field('members', { boost: 4 }); + this.field('keywords', { boost: 2 }); + loadIndex(this); + }); +} + +// The worker receives a message to load the index and to query the index +function handleMessage(message: { data: WebWorkerMessage }): void { + const type = message.data.type; + const id = message.data.id; + const payload = message.data.payload; + switch (type) { + case 'load-index': + makeRequest(SEARCH_TERMS_URL, function (searchInfo: PageInfo[]) { + index = createIndex(loadIndex(searchInfo)); + postMessage({ type: type, id: id, payload: true }); + }); + break; + case 'query-index': + postMessage({ type: type, id: id, payload: { query: payload, results: queryIndex(payload) } }); + break; + default: + postMessage({ type: type, id: id, payload: { error: 'invalid message type' } }) + } +} + +// Use XHR to make a request to the server +function makeRequest(url: string, callback: (response: any) => void): void { + + // The JSON file that is loaded should be an array of PageInfo: + const searchDataRequest = new XMLHttpRequest(); + searchDataRequest.onload = function () { + callback(JSON.parse(this.responseText)); + }; + searchDataRequest.open('GET', url); + searchDataRequest.send(); +} + + +// Create the search index from the searchInfo which contains the information about each page to be indexed +function loadIndex(pagesData: PageInfo[]): IndexLoader { + return (indexBuilder: lunr.Builder) => { + // Store the pages data to be used in mapping query results back to pages + // Add search terms from each page to the search index + pagesData.forEach(page => { + indexBuilder.add(page); + pages[page.path] = page; + }); + }; +} + +// Query the index and return the processed results +function queryIndex(query: string): PageInfo[] { + try { + if (query.length) { + let results = index.search(query); + if (results.length === 0) { + // Add a relaxed search in the title for the first word in the query + // E.g. if the search is "ngCont guide" then we search for "ngCont guide titleWords:ngCont*" + const titleQuery = 'titleWords:*' + query.split(' ', 1)[0] + '*'; + results = index.search(query + ' ' + titleQuery); + } + // Map the hits into info about each page to be returned as results + return results.map(function (hit) { return pages[hit.ref]; }); + } + } catch (e) { + // If the search query cannot be parsed the index throws an error + // Log it and recover + console.log(e); + } + return []; +} + +type IndexLoader = (indexBuilder: lunr.Builder) => void; diff --git a/aio/src/app/search/search.service.spec.ts b/aio/src/app/search/search.service.spec.ts index 27ededb7bf..4fad506ac0 100644 --- a/aio/src/app/search/search.service.spec.ts +++ b/aio/src/app/search/search.service.spec.ts @@ -25,11 +25,11 @@ describe('SearchService', () => { describe('initWorker', () => { it('should create the worker and load the index after the specified delay', fakeAsync(() => { - service.initWorker('some/url', 100); + service.initWorker(100); expect(WebWorkerClient.create).not.toHaveBeenCalled(); expect(mockWorker.sendMessage).not.toHaveBeenCalled(); tick(100); - expect(WebWorkerClient.create).toHaveBeenCalledWith('some/url', jasmine.any(NgZone)); + expect(WebWorkerClient.create).toHaveBeenCalledWith(jasmine.any(Worker), jasmine.any(NgZone)); expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index'); })); }); @@ -37,7 +37,7 @@ describe('SearchService', () => { describe('search', () => { beforeEach(() => { // We must initialize the service before calling connectSearches - service.initWorker('some/url', 1000); + service.initWorker(1000); // Simulate the index being ready so that searches get sent to the worker (service as any).ready = of(true); }); diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts index 03a463eb5c..1c0d34f4df 100644 --- a/aio/src/app/search/search.service.ts +++ b/aio/src/app/search/search.service.ts @@ -16,10 +16,9 @@ export class SearchService { * initial rendering of the web page. Triggering a search will override this delay and cause the index to be * loaded immediately. * - * @param workerUrl the url of the WebWorker script that runs the searches * @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index */ - initWorker(workerUrl: string, initDelay: number) { + initWorker(initDelay: number) { // Wait for the initDelay or the first search const ready = this.ready = race( timer(initDelay), @@ -28,7 +27,8 @@ export class SearchService { .pipe( concatMap(() => { // Create the worker and load the index - this.worker = WebWorkerClient.create(workerUrl, this.zone); + const worker = new Worker('./search-worker', { type: 'module' }); + this.worker = WebWorkerClient.create(worker, this.zone); return this.worker.sendMessage('load-index'); }), publishReplay(1), diff --git a/aio/src/app/shared/web-worker-message.ts b/aio/src/app/shared/web-worker-message.ts new file mode 100644 index 0000000000..36d2a69e36 --- /dev/null +++ b/aio/src/app/shared/web-worker-message.ts @@ -0,0 +1,5 @@ +export interface WebWorkerMessage { + type: string; + payload: any; + id?: number; +} diff --git a/aio/src/app/shared/web-worker.ts b/aio/src/app/shared/web-worker.ts index 07e545c5b6..9ed5e3e12f 100644 --- a/aio/src/app/shared/web-worker.ts +++ b/aio/src/app/shared/web-worker.ts @@ -1,17 +1,12 @@ import {NgZone} from '@angular/core'; import {Observable} from 'rxjs'; - -export interface WebWorkerMessage { - type: string; - payload: any; - id?: number; -} +import {WebWorkerMessage} from './web-worker-message'; export class WebWorkerClient { private nextId = 0; - static create(workerUrl: string, zone: NgZone) { - return new WebWorkerClient(new Worker(workerUrl), zone); + static create(worker: Worker, zone: NgZone) { + return new WebWorkerClient(worker, zone); } private constructor(private worker: Worker, private zone: NgZone) { diff --git a/aio/src/tsconfig.app.json b/aio/src/tsconfig.app.json index 64a2623a35..913911931a 100644 --- a/aio/src/tsconfig.app.json +++ b/aio/src/tsconfig.app.json @@ -1,19 +1,23 @@ { - "extends": "../tsconfig.json", + "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", - "module": "es2015", "baseUrl": "", "types": [], + "lib": [ + "es2018", + "dom" + ], "importHelpers": true }, "exclude": [ "testing/**/*", "test.ts", "test-specs.ts", - "**/*.spec.ts" + "**/*.spec.ts", + "**/*-worker.ts" ], "angularCompilerOptions": { "disableTypeScriptVersionCheck": true } -} \ No newline at end of file +} diff --git a/aio/src/tsconfig.json b/aio/src/tsconfig.json new file mode 100644 index 0000000000..8f45d18442 --- /dev/null +++ b/aio/src/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "es2015", + "lib": [ + "es2018", + "dom", + "webworker" + ], + } +} diff --git a/aio/src/tsconfig.worker.json b/aio/src/tsconfig.worker.json new file mode 100644 index 0000000000..a8f4ca8fbc --- /dev/null +++ b/aio/src/tsconfig.worker.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [ + "lunr" + ], + }, + "include": ["**/*-worker.ts"] +} diff --git a/aio/yarn.lock b/aio/yarn.lock index 3483145425..610d1d05dd 100644 --- a/aio/yarn.lock +++ b/aio/yarn.lock @@ -372,6 +372,11 @@ dependencies: "@types/jasmine" "*" +"@types/lunr@^2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@types/lunr/-/lunr-2.3.2.tgz#d4a51703315ed0e53c43257216f1014ce6491562" + integrity sha512-zcUZYquYDUEegRRPQtkZ068U9CoIjW6pJMYCVDRK25r76FEWvMm1oHqZQUfQh4ayIZ42lipXOpXEiAtGXc1XUg== + "@types/mkdirp@^0.3.29": version "0.3.29" resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.3.29.tgz#7f2ad7ec55f914482fc9b1ec4bb1ae6028d46066"