diff --git a/.travis.yml b/.travis.yml index b906bfc..ad051fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,11 @@ language: node_js node_js: "4.2.1" + +before_install: + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export CHROME_BIN=chromium-browser; fi # Karma CI + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export DISPLAY=:99.0; fi + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh -e /etc/init.d/xvfb start; fi + install: ./install.sh script: ./test.sh + diff --git a/app-shell/angular-cli-build.js b/app-shell/angular-cli-build.js index d5446de..fa1ce93 100644 --- a/app-shell/angular-cli-build.js +++ b/app-shell/angular-cli-build.js @@ -11,7 +11,8 @@ module.exports = function(defaults) { 'es6-shim/es6-shim.js', 'reflect-metadata/*.js', 'rxjs/**/*.js', - '@angular/**/*.js' + '@angular/**/*.js', + 'parse5/**/*.js' ] }); }; diff --git a/app-shell/package.json b/app-shell/package.json index 62712b9..d1aa4d0 100644 --- a/app-shell/package.json +++ b/app-shell/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser-dynamic": "2.0.0-rc.0", "@angular/router": "2.0.0-rc.0", "es6-shim": "^0.35.0", + "parse5": "https://github.com/mgechev/parse5", "reflect-metadata": "0.1.3", "rxjs": "5.0.0-beta.6", "systemjs": "0.19.26", @@ -40,6 +41,6 @@ "ts-node": "^0.5.5", "tslint": "^3.6.0", "typescript": "^1.8.10", - "typings": "^0.8.1" + "typings": "^1.0.4" } } diff --git a/app-shell/src/app/index.ts b/app-shell/src/app/index.ts index f628000..25cabf0 100644 --- a/app-shell/src/app/index.ts +++ b/app-shell/src/app/index.ts @@ -5,6 +5,7 @@ import { ShellNoRender } from './shell-no-render.directive'; export * from './is-prerender.service'; export * from './shell-no-render.directive'; export * from './shell-render.directive'; +export * from './shell-parser'; export const APP_SHELL_DIRECTIVES: Type[] = [ ShellRender, diff --git a/app-shell/src/app/shell-parser/ast/ast-node.ts b/app-shell/src/app/shell-parser/ast/ast-node.ts new file mode 100644 index 0000000..59b8794 --- /dev/null +++ b/app-shell/src/app/shell-parser/ast/ast-node.ts @@ -0,0 +1,12 @@ +export interface ASTAttribute { + name: string; + value: string; +} + +export interface ASTNode { + attrs: ASTAttribute[]; + childNodes?: ASTNode[]; + parentNode?: ASTNode; + nodeName: string; +} + diff --git a/app-shell/src/app/shell-parser/ast/index.ts b/app-shell/src/app/shell-parser/ast/index.ts new file mode 100644 index 0000000..ddebadd --- /dev/null +++ b/app-shell/src/app/shell-parser/ast/index.ts @@ -0,0 +1,2 @@ +export * from './ast-node'; + diff --git a/app-shell/src/app/shell-parser/config.ts b/app-shell/src/app/shell-parser/config.ts new file mode 100644 index 0000000..3bd7635 --- /dev/null +++ b/app-shell/src/app/shell-parser/config.ts @@ -0,0 +1,24 @@ +export type RouteDefinition = string; + +const SHELL_PARSER_CACHE_NAME = 'mobile-toolkit:app-shell'; +const APP_SHELL_URL = './app_shell.html'; +const NO_RENDER_CSS_SELECTOR = '.shell-no-render'; +const ROUTE_DEFINITIONS: RouteDefinition[] = []; + +// TODO(mgechev): use if we decide to include @angular/core +// export const SHELL_PARSER_CONFIG = new OpaqueToken('ShellRuntimeParserConfig'); + +export interface ShellParserConfig { + APP_SHELL_URL?: string; + SHELL_PARSER_CACHE_NAME?: string; + NO_RENDER_CSS_SELECTOR?: string; + ROUTE_DEFINITIONS?: RouteDefinition[]; +} + +export const SHELL_PARSER_DEFAULT_CONFIG: ShellParserConfig = { + SHELL_PARSER_CACHE_NAME, + APP_SHELL_URL, + NO_RENDER_CSS_SELECTOR, + ROUTE_DEFINITIONS +}; + diff --git a/app-shell/src/app/shell-parser/context.ts b/app-shell/src/app/shell-parser/context.ts new file mode 100644 index 0000000..8550a35 --- /dev/null +++ b/app-shell/src/app/shell-parser/context.ts @@ -0,0 +1,25 @@ +export abstract class WorkerScope { + abstract fetch(url: string | Request): Promise; + abstract newRequest(input: string | Request, init?: RequestInit): Request; + abstract newResponse(body?: BodyInit, init?: ResponseInit): Response; + caches: CacheStorage; +} + +export class BrowserWorkerScope { + fetch(url: string | Request): Promise { + return fetch(url); + } + + get caches() { + return caches; + } + + newRequest(input: string | Request, init?: RequestInit): Request { + return new Request(input, init); + } + + newResponse(body?: BodyInit, init?: ResponseInit) { + return new Response(body, init); + } +} + diff --git a/app-shell/src/app/shell-parser/index.ts b/app-shell/src/app/shell-parser/index.ts new file mode 100644 index 0000000..9578d29 --- /dev/null +++ b/app-shell/src/app/shell-parser/index.ts @@ -0,0 +1,11 @@ +import {ShellParser} from './shell-parser'; +import {shellParserFactory} from './shell-parser-factory'; +import {RouteDefinition, ShellParserConfig} from './config'; + +export { + shellParserFactory, + ShellParser, + RouteDefinition, + ShellParserConfig +}; + diff --git a/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.spec.ts b/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.spec.ts new file mode 100644 index 0000000..88d9bd0 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.spec.ts @@ -0,0 +1,126 @@ +import { + beforeEach, + it, + describe, + expect, + inject +} from '@angular/core/testing'; +import { ASTNode } from '../ast'; +import { CssSelector } from './css-selector'; +import { CssNodeMatcher } from './css-node-matcher'; + +describe('CssNodeMatcher', () => { + + var node: ASTNode; + beforeEach(() => { + node = { + attrs: [ + { name: 'foo', value: 'bar' }, + { name: 'class', value: 'dialog modal--drop' }, + { name: 'id', value: 'dialog-id' } + ], + nodeName: 'div', + parentNode: null + }; + }); + + describe('successful match', () => { + + it('should match any node with empty selector', () => { + const emptySelector = CssSelector.parse(''); + const selector = new CssNodeMatcher(emptySelector); + expect(selector.match(node)).toBe(true); + }); + + it('should match basic element selector', () => { + const elementSelector = CssSelector.parse('div'); + const selector = new CssNodeMatcher(elementSelector); + expect(selector.match(node)).toBe(true); + }); + + it('should match attribute selector', () => { + const attrSelector = CssSelector.parse('[foo=bar]'); + const selector = new CssNodeMatcher(attrSelector); + expect(selector.match(node)).toBe(true); + }); + + it('should match attribute selector when no value is provided', () => { + const attrSelector = CssSelector.parse('[foo]'); + const selector = new CssNodeMatcher(attrSelector); + expect(selector.match(node)).toBe(true); + }); + + it('should match class selector', () => { + const classSelector = CssSelector.parse('.dialog'); + const selector = new CssNodeMatcher(classSelector); + expect(selector.match(node)).toBe(true); + const complexClassSelector = CssSelector.parse('.dialog.modal--drop'); + const complexSelector = new CssNodeMatcher(complexClassSelector); + expect(complexSelector.match(node)).toBe(true); + }); + + it('should match element by id', () => { + const idSelector = CssSelector.parse('#dialog-id'); + const selector = new CssNodeMatcher(idSelector); + expect(selector.match(node)).toBe(true); + }); + + }); + + describe('unsuccessful match', () => { + + it('should fail when different element is used', () => { + const elementSelector = CssSelector.parse('span'); + const selector = new CssNodeMatcher(elementSelector); + expect(selector.match(node)).toBe(false); + }); + + it('should fail when different attribute selector is provided', () => { + const attrSelector = CssSelector.parse('[foo=qux]'); + const selector = new CssNodeMatcher(attrSelector); + expect(selector.match(node)).toBe(false); + }); + + it('should fail when non-matching class selector is used', () => { + const classSelector = CssSelector.parse('.modal'); + const selector = new CssNodeMatcher(classSelector); + expect(selector.match(node)).toBe(false); + const complexClassSelector = CssSelector.parse('.dialog.modal-drop'); + const complexSelector = new CssNodeMatcher(complexClassSelector); + expect(complexSelector.match(node)).toBe(false); + }); + + it('should fail when superset of attributes is used in selector', () => { + const cssSelector = CssSelector.parse('[foo=bar][baz=bar]'); + const selector = new CssNodeMatcher(cssSelector); + expect(selector.match(node)).toBe(false); + }); + + it('should fail when superset of attributes is used in selector', () => { + const cssSelector = CssSelector.parse('[no-render]'); + const selector = new CssNodeMatcher(cssSelector); + expect(selector.match(node)).toBe(false); + }) + + it('should fail match by id when element has no id', () => { + const cssSelector = CssSelector.parse('#foo'); + const selector = new CssNodeMatcher(cssSelector); + const node1: ASTNode = { + nodeName: 'div', + parentNode: null, + attrs: [] + }; + const node2: ASTNode = { + attrs: [ + { name: 'not-id', value: '' } + ], + nodeName: 'div', + parentNode: null + }; + expect(selector.match(node1)).toBe(false); + expect(selector.match(node2)).toBe(false); + }); + + }); +}); + diff --git a/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.ts b/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.ts new file mode 100644 index 0000000..e2da630 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/css-node-matcher.ts @@ -0,0 +1,80 @@ +import {NodeMatcher} from './node-matcher'; +import {ASTNode, ASTAttribute} from '../ast'; +import {CssSelector} from './css-selector'; + +export const cssNodeMatcherFactory = (selector: string) => { + return new CssNodeMatcher(CssSelector.parse(selector)); +}; + +export class CssNodeMatcher extends NodeMatcher { + constructor(private selector: CssSelector) { + super(); + } + + match(node: ASTNode): boolean { + return this.matchElement(node) && this.matchAttributes(node) && + this.matchId(node) && this.matchClassNames(node); + } + + private matchElement(node: ASTNode) { + return !this.selector.element || this.selector.element === node.nodeName; + } + + private matchAttributes(node: ASTNode) { + const selectorAttrs = this.selector.attrs; + const nodeAttrs = (node.attrs || []).reduce((accum: any, attr: ASTAttribute) => { + accum[attr.name] = attr.value; + return accum; + }, {}); + const selectorAttrNames = Object.keys(selectorAttrs); + if (!selectorAttrNames.length) { + return true; + } + return selectorAttrNames.reduce((accum: boolean, name: string) => { + return accum && (selectorAttrs[name] === nodeAttrs[name] || + // nodeAttrs[name] cannot be undefined after parsing + // since it'll be normalized to name="" if empty + (nodeAttrs[name] !== undefined && selectorAttrs[name] === '')); + }, true); + } + + private matchClassNames(node: ASTNode) { + const selectorClasses = this.selector.classNames; + if (!selectorClasses.length) { + return true; + } + const classAttr = this.getAttribute(node, 'class'); + // We have selector by class but we don't have class attribute of the node + if (!classAttr) { + return false; + } + const classMap = classAttr.value.split(' ') + .reduce((accum: any, val: string) => { + accum[val] = true; + return accum; + }, {}); + return selectorClasses.reduce((accum: boolean, val: string) => { + return accum && !!classMap[val]; + }, true); + } + + private matchId(node: ASTNode) { + const id = this.selector.elementId; + if (!id) { + return true; + } + const idAttr = this.getAttribute(node, 'id'); + if (idAttr && idAttr.value === this.selector.elementId) { + return true; + } else { + return false; + } + } + + private getAttribute(node: ASTNode, attrName: string) { + return (node.attrs || []) + .filter((attr: ASTAttribute) => + attr.name === attrName).pop(); + } +} + diff --git a/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.spec.ts b/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.spec.ts new file mode 100644 index 0000000..85ddb81 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.spec.ts @@ -0,0 +1,62 @@ +import { + beforeEachProviders, + it, + describe, + expect, + inject +} from '@angular/core/testing'; +import { CssSelector } from './css-selector'; + +describe('CssSelector', () => { + + it('should support id selectors', () => { + const result = CssSelector.parse('#elemId'); + expect(result.elementId).toBe('elemId'); + }); + + it('should support element selectors', () => { + const result = CssSelector.parse('div'); + expect(result.element).toBe('div'); + }); + + it('should support class selectors', () => { + const result = CssSelector.parse('.foo'); + expect(result.classNames).toBeTruthy(); + expect(result.classNames.length).toBe(1); + expect(result.classNames[0]).toBe('foo'); + }); + + describe('attribute selectors', () => { + + it('should support attributes with no values', () => { + const result = CssSelector.parse('[title]'); + expect(result.attrs['title']).toBe(''); + }); + + it('should support attributes with values', () => { + const result = CssSelector.parse('[title=random title]'); + expect(result.attrs['title']).toBe('random title'); + }); + + }); + + describe('complex selectors', () => { + + it('should support complex selectors', () => { + const result = CssSelector.parse('div.foo[attr]'); + expect(result.element).toBe('div'); + expect(result.classNames[0]).toBe('foo'); + expect(result.attrs['attr']).toBe(''); + }); + + it('should support complex selectors with terminals in random order', () => { + const result = CssSelector.parse('.foo[attr=bar].qux#baz'); + expect(result.element).toBe(null); + expect(result.attrs['attr']).toBe('bar'); + expect(result.elementId).toBe('baz'); + expect(result.classNames).toEqual(['foo', 'qux']); + }); + + }); +}); + diff --git a/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.ts b/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.ts new file mode 100644 index 0000000..eb0f9e0 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/css-selector/css-selector.ts @@ -0,0 +1,55 @@ +const _EMPTY_ATTR_VALUE = ''; +const _SELECTOR_REGEXP = new RegExp( + '([-\\w]+)|' + // "tag" + '(?:\\.([-\\w]+))|' + // ".class" + '(?:\\#([-\\w]+))|' + // "#id" + '(?:\\[([-\\w]+)(?:=[\'"]?([^\\]]*)[\'"]?)?\\])', // "[name]" or "[name=value]" + 'g'); + +export class CssSelector { + element: string = null; + elementId: string = null; + classNames: string[] = []; + attrs: {[key: string]: string} = {}; + + static parse(selector: string): CssSelector { + var cssSelector = new CssSelector(); + var match: RegExpExecArray; + _SELECTOR_REGEXP.lastIndex = 0; + while ((match = _SELECTOR_REGEXP.exec(selector)) !== null) { + if (match[1]) { + cssSelector.setElement(match[1]); + } + if (match[2]) { + cssSelector.addClassName(match[2]); + } + if (match[3]) { + cssSelector.addId(match[3]); + } + if (match[4]) { + cssSelector.addAttribute(match[4], match[5]); + } + } + return cssSelector; + } + + setElement(element: string = null) { + this.element = element; + } + + addId(name: string) { + this.elementId = name; + } + + addAttribute(name: string, value: string) { + if (value === undefined) { + value = _EMPTY_ATTR_VALUE; + } + this.attrs[name] = value; + } + + addClassName(name: string) { + this.classNames.push(name.toLowerCase()); + } +} + diff --git a/app-shell/src/app/shell-parser/node-matcher/css-selector/index.ts b/app-shell/src/app/shell-parser/node-matcher/css-selector/index.ts new file mode 100644 index 0000000..c33a033 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/css-selector/index.ts @@ -0,0 +1,2 @@ +export * from './css-selector'; + diff --git a/app-shell/src/app/shell-parser/node-matcher/index.ts b/app-shell/src/app/shell-parser/node-matcher/index.ts new file mode 100644 index 0000000..d377064 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/index.ts @@ -0,0 +1,4 @@ +export * from './css-selector'; +export * from './node-matcher'; +export * from './css-node-matcher'; + diff --git a/app-shell/src/app/shell-parser/node-matcher/node-matcher.ts b/app-shell/src/app/shell-parser/node-matcher/node-matcher.ts new file mode 100644 index 0000000..ba6bd29 --- /dev/null +++ b/app-shell/src/app/shell-parser/node-matcher/node-matcher.ts @@ -0,0 +1,6 @@ +import {ASTNode} from '../ast'; + +export abstract class NodeMatcher { + abstract match(node: ASTNode): boolean; +} + diff --git a/app-shell/src/app/shell-parser/shell-parser-factory.ts b/app-shell/src/app/shell-parser/shell-parser-factory.ts new file mode 100644 index 0000000..be6de8d --- /dev/null +++ b/app-shell/src/app/shell-parser/shell-parser-factory.ts @@ -0,0 +1,19 @@ +import {Parse5TemplateParser} from './template-parser'; +import {ShellParserImpl} from './shell-parser'; +import {cssNodeMatcherFactory} from './node-matcher'; +import {BrowserWorkerScope} from './context'; +import {ShellParserConfig, SHELL_PARSER_DEFAULT_CONFIG} from './config'; + +export const normalizeConfig = (config: ShellParserConfig) => { + return Object.assign(Object.assign({}, SHELL_PARSER_DEFAULT_CONFIG), config); +}; + +export const shellParserFactory = (config: ShellParserConfig = {}) => { + const parserConfig = normalizeConfig(config); + return new ShellParserImpl( + parserConfig, + new Parse5TemplateParser(), + cssNodeMatcherFactory(parserConfig.NO_RENDER_CSS_SELECTOR), + new BrowserWorkerScope()); +}; + diff --git a/app-shell/src/app/shell-parser/shell-parser.spec.ts b/app-shell/src/app/shell-parser/shell-parser.spec.ts new file mode 100644 index 0000000..3ab0e2a --- /dev/null +++ b/app-shell/src/app/shell-parser/shell-parser.spec.ts @@ -0,0 +1,279 @@ +import { + beforeEach, + it, + xit, + describe, + expect, + inject +} from '@angular/core/testing'; +import {BrowserWorkerScope, WorkerScope} from './context'; +import {ShellParserConfig, SHELL_PARSER_DEFAULT_CONFIG} from './config'; +import {cssNodeMatcherFactory} from './node-matcher'; +import {Parse5TemplateParser} from './template-parser'; +import {normalizeConfig} from './shell-parser-factory'; +import {ShellParserImpl} from './shell-parser'; +import { + MockWorkerScope, + MockResponse, + MockRequest, + MockCacheStorage +} from './testing'; + +const prerenderedTemplate = ` + + + + + +
+

Hey I'm appshell!

+
+ +

Hello world

+
+
+
+ + +`; + +const strippedWithDefaultSelector = ` + + + + + +
+

Hey I'm appshell!

+
+ + +`; + +const strippedContent = ` + + + + + +
+

Hey I'm appshell!

+
+
+
+ + +`; + +const strippedWithComposedSelector = ` + + + + + +
+

Hey I'm appshell!

+
+ +

Hello world

+
+ + +`; + +const normalize = (template: string) => + template + .replace(/^\s+/gm, '') + .replace(/\s+$/gm, '') + .replace(/\n/gm, ''); + +const createMockedWorker = (mockScope: MockWorkerScope, config: ShellParserConfig = {}) => { + config = normalizeConfig(config); + return new ShellParserImpl( + config, + new Parse5TemplateParser(), + cssNodeMatcherFactory(config.NO_RENDER_CSS_SELECTOR), + mockScope); +}; + +describe('ShellParserImpl', () => { + + describe('fetch', () => { + + it('should use the default url by default', (done: any) => { + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope); + mockScope.mockResponses[SHELL_PARSER_DEFAULT_CONFIG.APP_SHELL_URL] = new MockResponse('foo'); + parser.fetchDoc() + .then((res: MockResponse) => res.text()) + .then((data: string) => { + expect(data).toBe('foo'); + done(); + }); + }); + + it('should use the configured url when set', (done: any) => { + const url = './view-for-app-shell.html'; + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope, { + APP_SHELL_URL: url + }); + mockScope.mockResponses[url] = new MockResponse('bar'); + parser.fetchDoc() + .then((res: MockResponse) => res.text()) + .then((data: string) => { + expect(data).toBe('bar'); + done(); + }); + }); + }); + + describe('parseDoc', () => { + + it('should strip with default selector', (done: any) => { + debugger; + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope); + const response = new MockResponse(prerenderedTemplate); + parser.parseDoc(response) + .then((response: any) => response.text()) + .then((template: string) => { + expect(normalize(template)).toBe(normalize(strippedWithDefaultSelector)); + done(); + }); + }); + + it('should strip with nested selector', (done: any) => { + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope, { + NO_RENDER_CSS_SELECTOR: 'content.shell-no-render' + }); + const response = new MockResponse(prerenderedTemplate); + parser.parseDoc(response) + .then((response: any) => response.text()) + .then((template: string) => { + expect(normalize(template)).toBe(normalize(strippedContent)); + done(); + }); + }); + + it('should strip with nested selector', (done: any) => { + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope, { + NO_RENDER_CSS_SELECTOR: '.shell-no-render.bar' + }); + const response = new MockResponse(prerenderedTemplate); + parser.parseDoc(response) + .then((response: any) => response.text()) + .then((template: string) => { + expect(normalize(template)).toBe(normalize(strippedWithComposedSelector)); + done(); + }); + }); + + it('should return content type "text/html" with status 200', (done: any) => { + const mockScope = new MockWorkerScope(); + const parser = createMockedWorker(mockScope, { + NO_RENDER_CSS_SELECTOR: '.shell-no-render.bar' + }); + const response = new MockResponse(prerenderedTemplate); + parser.parseDoc(response) + .then((response: any) => { + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/html'); + }) + .then(done); + }); + + describe('match', () => { + + it('should match routes added to the config', (done: any) => { + const mockScope = new MockWorkerScope(); + const SHELL_PARSER_CACHE_NAME = 'mock'; + const APP_SHELL_URL = './shell.html'; + mockScope.currentCaches = new MockCacheStorage(); + mockScope.caches + .open(SHELL_PARSER_CACHE_NAME) + .then((cache: any) => { + cache.put(new MockRequest(APP_SHELL_URL), new MockResponse('foo')); + const parser = createMockedWorker(mockScope, { + SHELL_PARSER_CACHE_NAME, + APP_SHELL_URL, + ROUTE_DEFINITIONS: ['/home', '/about/new'] + }); + Promise.all([ + parser.match(new MockRequest('/home')) + .then((response: MockResponse) => response.text()) + .then((data: string) => { + expect(data).toBe('foo'); + return data; + }), + parser.match(new MockRequest('/about/new')) + .then((response: MockResponse) => response.text()) + .then((data: string) => { + expect(data).toBe('foo'); + return data; + }) + ]) + .then(done); + }); + }); + + it('should match routes with parameters added to the config', (done: any) => { + const mockScope = new MockWorkerScope(); + const SHELL_PARSER_CACHE_NAME = 'mock'; + const APP_SHELL_URL = './shell.html'; + mockScope.currentCaches = new MockCacheStorage(); + mockScope.caches + .open(SHELL_PARSER_CACHE_NAME) + .then((cache: any) => { + cache.put(new MockRequest(APP_SHELL_URL), new MockResponse('foo')); + const parser = createMockedWorker(mockScope, { + SHELL_PARSER_CACHE_NAME, + APP_SHELL_URL, + ROUTE_DEFINITIONS: ['/home/:id', '/about/:bar/:foo/new/:baz'] + }); + Promise.all([ + parser.match(new MockRequest('/home/312')) + .then((response: MockResponse) => response.text()) + .then((data: string) => { + expect(data).toBe('foo'); + return data; + }), + parser.match(new MockRequest('/about/42/12/new/foo')) + .then((response: MockResponse) => response.text()) + .then((data: string) => { + expect(data).toBe('foo'); + return data; + }) + ]) + .then(done); + }); + }); + + it('should return falsy value for non-matching route', (done: any) => { + const mockScope = new MockWorkerScope(); + const SHELL_PARSER_CACHE_NAME = 'mock'; + const APP_SHELL_URL = './shell.html'; + mockScope.currentCaches = new MockCacheStorage(); + mockScope.caches + .open(SHELL_PARSER_CACHE_NAME) + .then((cache: any) => { + cache.put(new MockRequest(APP_SHELL_URL), new MockResponse('foo')); + const parser = createMockedWorker(mockScope, { + SHELL_PARSER_CACHE_NAME, + APP_SHELL_URL, + ROUTE_DEFINITIONS: ['/about/:bar/:foo/new/:another'] + }); + parser.match(new MockRequest('/home')) + .then((data: any) => { + expect(data).toBe(null); + done(); + }) + }); + }); + + }); + }); +}); + diff --git a/app-shell/src/app/shell-parser/shell-parser.ts b/app-shell/src/app/shell-parser/shell-parser.ts new file mode 100644 index 0000000..531acc8 --- /dev/null +++ b/app-shell/src/app/shell-parser/shell-parser.ts @@ -0,0 +1,90 @@ +import {RouteDefinition, ShellParserConfig} from './config'; +import {ASTNode} from './ast'; +import {NodeMatcher} from './node-matcher'; +import {TemplateParser} from './template-parser'; +import {WorkerScope} from './context'; + +export interface ShellParser { + fetchDoc(url?: string): Promise; + parseDoc(res: Response): Promise; + match(req: Request): Promise; +} + +export class ShellParserImpl implements ShellParser { + constructor(private config: ShellParserConfig, + private parser: TemplateParser, + private selector: NodeMatcher, + private scope: WorkerScope) {} + + fetchDoc(url: string = this.config.APP_SHELL_URL): Promise { + return this.scope.fetch(url); + } + + parseDoc(res: Response): Promise { + return res.text() + .then((template: string) => { + const headers: any = { + 'content-type': 'text/html' + }; + return this.scope.newResponse(this.stripTemplate(template), { + status: 200, + headers + }); + }); + } + + match(req: Request): Promise { + if (req.method !== 'GET') { + return Promise.resolve(null); + } + const matchedRoute = this.routeMatcher(this.config.ROUTE_DEFINITIONS, req.url).pop(); + if (!matchedRoute) { + return Promise.resolve(null); + } + return this.scope.caches.open(this.config.SHELL_PARSER_CACHE_NAME) + .then((cache: Cache) => + cache.match(this.scope.newRequest(this.config.APP_SHELL_URL))); + } + + private stripTemplate(template: string) { + const root = this.parser.parse(template); + this.stripTemplateHelper(root); + return this.parser.serialize(root); + } + + private stripTemplateHelper(node: ASTNode): ASTNode { + const children = node.childNodes || []; + if (this.selector.match(node)) { + const parentNode = node.parentNode; + if (parentNode) { + parentNode.childNodes + .splice(parentNode.childNodes.indexOf(node), 1); + } else { + // Ths is the root node so markup + // should be included in App Shell + return null; + } + } else { + children.forEach((c: ASTNode) => + this.stripTemplateHelper(c)); + } + } + + private routeMatcher(definitions: RouteDefinition[], url: string) { + const urlParts = url.split('/'); + let definitionsParts = definitions.map(def => this.scope.newRequest(def).url.split('/')) + .filter(def => urlParts.length === def.length); + let currentIdx = 0; + while (definitionsParts.length > 0 && urlParts.length > currentIdx) { + definitionsParts = definitionsParts.filter(defParts => { + if (defParts[currentIdx][0] === ':') { + return true; + } + return defParts[currentIdx] === urlParts[currentIdx]; + }); + currentIdx += 1; + } + return definitionsParts.map(parts => parts.join('/')); + } +} + diff --git a/app-shell/src/app/shell-parser/template-parser/index.ts b/app-shell/src/app/shell-parser/template-parser/index.ts new file mode 100644 index 0000000..5cd6ced --- /dev/null +++ b/app-shell/src/app/shell-parser/template-parser/index.ts @@ -0,0 +1,3 @@ +export * from './template-parser'; +export * from './parse5-template-parser'; + diff --git a/app-shell/src/app/shell-parser/template-parser/parse5-template-parser.ts b/app-shell/src/app/shell-parser/template-parser/parse5-template-parser.ts new file mode 100644 index 0000000..1fafec2 --- /dev/null +++ b/app-shell/src/app/shell-parser/template-parser/parse5-template-parser.ts @@ -0,0 +1,14 @@ +import {ASTNode} from '../ast'; +import {TemplateParser} from './template-parser'; +import * as Parse5 from 'parse5'; + +export class Parse5TemplateParser extends TemplateParser { + parse(template: string): ASTNode { + return Parse5.parse(template); + } + + serialize(node: ASTNode): string { + return Parse5.serialize(node); + } +} + diff --git a/app-shell/src/app/shell-parser/template-parser/template-parser.ts b/app-shell/src/app/shell-parser/template-parser/template-parser.ts new file mode 100644 index 0000000..d072fed --- /dev/null +++ b/app-shell/src/app/shell-parser/template-parser/template-parser.ts @@ -0,0 +1,7 @@ +import {ASTNode} from '../ast'; + +export abstract class TemplateParser { + abstract parse(template: string): ASTNode; + abstract serialize(node: ASTNode): string; +} + diff --git a/app-shell/src/app/shell-parser/testing/context-mock.ts b/app-shell/src/app/shell-parser/testing/context-mock.ts new file mode 100644 index 0000000..3ce6410 --- /dev/null +++ b/app-shell/src/app/shell-parser/testing/context-mock.ts @@ -0,0 +1,33 @@ +import {MockRequest, MockResponse} from './mock-requests'; +import {MockCacheStorage} from './mock-caches'; +import {WorkerScope} from '../context'; + +export class MockWorkerScope { + mockResponses: {[key: string]: Response} = {}; + currentCaches: MockCacheStorage; + + fetch(url: string | Request): Promise { + const requestUrl: string = url; + if (this.mockResponses[requestUrl]) { + return Promise.resolve(this.mockResponses[requestUrl]); + } + const resp = new MockResponse(''); + resp.ok = false; + resp.status = 404; + resp.statusText = 'File Not Found'; + return Promise.resolve(resp); + } + + get caches(): CacheStorage { + return this.currentCaches; + } + + newRequest(input: string | Request, init?: RequestInit): Request { + return new MockRequest(input, init); + } + + newResponse(body?: BodyInit, init?: ResponseInit) { + return new MockResponse(body, init); + } +} + diff --git a/app-shell/src/app/shell-parser/testing/index.ts b/app-shell/src/app/shell-parser/testing/index.ts new file mode 100644 index 0000000..b25a56c --- /dev/null +++ b/app-shell/src/app/shell-parser/testing/index.ts @@ -0,0 +1,4 @@ +export * from './context-mock'; +export * from './mock-requests'; +export * from './mock-caches'; + diff --git a/app-shell/src/app/shell-parser/testing/mock-caches.ts b/app-shell/src/app/shell-parser/testing/mock-caches.ts new file mode 100644 index 0000000..feb47ff --- /dev/null +++ b/app-shell/src/app/shell-parser/testing/mock-caches.ts @@ -0,0 +1,125 @@ +function findIndex(array: any[], matcher: Function): number { + for (var i = 0; i < array.length; i++) { + if (matcher(array[i])) { + return i; + } + } + return -1; +} + +export class MockCacheStorage implements CacheStorage { + caches: {[key: string]: MockCache} = {}; + constructor() {} + + delete(cacheName: string): Promise { + if (this.caches.hasOwnProperty(cacheName)) { + delete (this).caches[cacheName]; + return Promise.resolve(true); + } + return Promise.resolve(false); + } + + has(cacheName: string): Promise { + return Promise.resolve(this.caches.hasOwnProperty(cacheName)); + } + + keys(): Promise { + var keys: any[] = []; + for (var cacheName in this.caches) { + keys.push(cacheName); + } + return Promise.resolve(keys); + } + + match(request: Request, options?: CacheOptions): Promise { + if (options !== undefined && options !== null) { + throw 'CacheOptions are unsupported'; + } + var promises: any[] = []; + for (var cacheName in this.caches) { + promises.push((this).caches[cacheName].match(request)); + } + promises.push(Promise.resolve(undefined)); + + var valueOrNextPromiseFn: Function = (value: any) => { + if (value !== undefined || promises.length === 0) { + return value; + } + return promises.shift().then(valueOrNextPromiseFn); + }; + + return promises.shift().then(valueOrNextPromiseFn); + } + + open(cacheName: string): Promise { + if (!this.caches.hasOwnProperty(cacheName)) { + (this).caches[cacheName] = new MockCache(); + } + return Promise.resolve((this).caches[cacheName]); + } +} + +export class MockCache implements Cache { + + entries: MockCacheEntry[] = []; + + add(request: Request): Promise { + throw 'Unimplemented'; + } + + addAll(requests: Request[]): Promise { + return Promise + .all(requests.map((req) => this.add(req))) + .then(() => undefined); + } + + delete(request: Request, options?: CacheOptions): Promise { + if (options !== undefined) { + throw 'CacheOptions are unsupported'; + } + var idx = findIndex(this.entries, (entry: any) => entry.match(request)); + if (idx !== -1) { + this.entries.splice(idx, 1); + } + return Promise.resolve(undefined); + } + + keys(request?: Request, options?: CacheOptions): Promise { + throw 'Unimplemented'; + } + + match(request: Request, options?: CacheOptions): Promise { + if (options !== undefined) { + throw 'CacheOptions are unsupported'; + } + var idx = findIndex(this.entries, (entry: any) => entry.match(request)); + if (idx === -1) { + return Promise.resolve(undefined); + } + return Promise.resolve(this.entries[idx].response.clone()); + } + + matchAll(request: Request, options?: CacheOptions): Promise { + if (options !== undefined) { + throw 'CacheOptions are unsupported'; + } + return Promise.resolve(this + .entries + .filter((entry) => entry.match(request)) + .map((entry) => entry.response.clone())); + } + + put(request: Request, response: Response): Promise { + this.entries.unshift(new MockCacheEntry(request, response)); + return Promise.resolve(undefined); + } +} + +export class MockCacheEntry { + constructor(public request: Request, public response: Response) {} + + match(req: Request): boolean { + return req.url === this.request.url && req.method === this.request.method; + } +} + diff --git a/app-shell/src/app/shell-parser/testing/mock-requests.ts b/app-shell/src/app/shell-parser/testing/mock-requests.ts new file mode 100644 index 0000000..ae24617 --- /dev/null +++ b/app-shell/src/app/shell-parser/testing/mock-requests.ts @@ -0,0 +1,114 @@ +export class MockBody { + bodyUsed: boolean = false; + + constructor(private _body: string) {} + + arrayBuffer(): Promise { + throw 'Unimplemented'; + } + + blob(): Promise { + throw 'Unimplemented'; + } + + formData(): Promise { + throw 'Unimplemented'; + } + + json(): Promise { + throw 'Unimplemented'; + } + + text(): Promise { + return Promise.resolve(this._body); + } + + get _mockBody(): string { + return this._body; + } +} + +export class MockRequest extends MockBody implements Request { + url: string; + method: string = "GET"; + cache: RequestCache = "default"; + + headers: any; + redirect: RequestRedirect; + get body(): any { + return this; + } + + mode: RequestMode; + context: RequestContext; + referrer: string; + credentials: RequestCredentials; + + constructor(req: string | Request, init?: {[key: string]: any}) { + super(null); + if (typeof req == 'string') { + this.url = req; + } else { + let other = req; + this.url = init['url'] || other.url; + this.method = other.method; + this.cache = other.cache; + this.headers = other.headers; + //this.body = other.body; + this.mode = other.mode; + this.context = other.context; + this.referrer = other.referrer; + this.credentials = other.credentials; + } + ['method', 'cache', 'headers', 'mode', 'context', 'referrer', 'credentials'] + .forEach(prop => this._copyProperty(prop, init)); + } + + _copyProperty(prop: string, from: Object) { + if (from && from.hasOwnProperty(prop)) { + (this)[prop] = (from)[prop]; + } + } + + matches(req: Request): boolean { + return req.url === this.url && req.method === this.method; + } +} + +export class MockResponse extends MockBody implements Response { + ok: boolean = true; + statusText: string = 'OK'; + status: number = 200; + url: string; + headers: any; + type: ResponseType = "default"; + + constructor(body: string | Blob | BodyInit, init?: ResponseInit) { + super(body); + if ((init || { headers: null }).headers) { + this.headers = init.headers; + } + } + + clone(): MockResponse { + if (this.bodyUsed) { + throw 'Body already consumed.'; + } + var resp = new MockResponse(this._mockBody); + resp.ok = this.ok; + resp.statusText = this.statusText; + resp.status = this.status; + resp.headers = this.headers; + resp.url = this.url; + return resp; + } + + error(): Response { + throw 'Unimplemented'; + } + + redirect(url: string, status: number): Response { + throw 'Unimplemented'; + } +} + diff --git a/app-shell/src/manual_typings/service-worker.d.ts b/app-shell/src/manual_typings/service-worker.d.ts new file mode 100644 index 0000000..3d392fe --- /dev/null +++ b/app-shell/src/manual_typings/service-worker.d.ts @@ -0,0 +1,29 @@ +declare class ServiceWorkerContext { +} + +declare interface Cache { + add(request: Request): Promise; + addAll(requests: Request[]): Promise; + delete(request: Request, options?: CacheOptions): Promise; + keys(request?: Request, options?: CacheOptions): Promise; + match(request: Request, options?: CacheOptions): Promise; + matchAll(request: Request, options?: CacheOptions): Promise; + put(request: Request, response: Response): Promise; +} + +declare interface CacheStorage { + delete(cacheName: string): Promise; + has(cacheName: string): Promise; + keys(): Promise; + match(request: Request, options?: CacheOptions): Promise; + open(cacheName: string): Promise; +} + +declare interface CacheOptions { + ignoreSearch?: boolean; + ignoreMethod?: boolean; + ignoreVary?: boolean; + cacheName?: string; +} + +declare var caches: CacheStorage; diff --git a/app-shell/src/system-config.ts b/app-shell/src/system-config.ts index f732456..5c3d7e6 100644 --- a/app-shell/src/system-config.ts +++ b/app-shell/src/system-config.ts @@ -11,19 +11,34 @@ const barrels: string[] = [ '@angular/platform-browser', '@angular/platform-browser-dynamic', + // Parse5 barrels + 'parse5', + 'parse5/parser', + 'parse5/serializer', + 'parse5/common', + 'parse5/tokenizer', + 'parse5/tree_adapter', + 'parse5/location_info', + 'rxjs', // App specific barrels. 'app', 'app/shared', + 'app/shell-parser', + 'app/shell-parser/template-parser', + 'app/shell-parser/node-matcher', + 'app/shell-parser/node-matcher/css-selector', + 'app/shell-parser/ast', + 'app/shell-parser/testing', /** @cli-barrel */ ]; - // Angular CLI SystemJS configuration. System.config({ map: { '@angular': 'vendor/@angular', + 'parse5': 'vendor/parse5/lib', 'rxjs': 'vendor/rxjs', 'main': 'main.js' }, diff --git a/app-shell/src/tsconfig.json b/app-shell/src/tsconfig.json index 2cbcf1f..833b312 100644 --- a/app-shell/src/tsconfig.json +++ b/app-shell/src/tsconfig.json @@ -20,6 +20,7 @@ "app/is-prerender.service.spec.ts", "app/shell-no-render.directive.spec.ts", "app/shell-render.directive.spec.ts", + "app/shell-parser/shell-parser.spec.ts", "typings.d.ts" ] } diff --git a/app-shell/src/tsconfig.publish.es5.json b/app-shell/src/tsconfig.publish.es5.json index 712e17b..c64ba3c 100644 --- a/app-shell/src/tsconfig.publish.es5.json +++ b/app-shell/src/tsconfig.publish.es5.json @@ -17,6 +17,6 @@ "files": [ "app/index.ts", - "../typings/browser/ambient/es6-shim/index.d.ts" + "typings.d.ts" ] } diff --git a/app-shell/src/tsconfig.publish.es6.json b/app-shell/src/tsconfig.publish.es6.json index 2de1fc4..7b7b13b 100644 --- a/app-shell/src/tsconfig.publish.es6.json +++ b/app-shell/src/tsconfig.publish.es6.json @@ -16,6 +16,7 @@ }, "files": [ - "app/index.ts" + "app/index.ts", + "typings.es6.d.ts" ] } diff --git a/app-shell/src/typings.d.ts b/app-shell/src/typings.d.ts index 2b42093..6b5faa4 100644 --- a/app-shell/src/typings.d.ts +++ b/app-shell/src/typings.d.ts @@ -1,3 +1,3 @@ -/// +/// +/// -declare var module: { id: string }; diff --git a/app-shell/src/typings.es6.d.ts b/app-shell/src/typings.es6.d.ts new file mode 100644 index 0000000..5800147 --- /dev/null +++ b/app-shell/src/typings.es6.d.ts @@ -0,0 +1,5 @@ +/// +/// +/// +/// + diff --git a/app-shell/typings.json b/app-shell/typings.json index a3cf3dd..31e9d15 100644 --- a/app-shell/typings.json +++ b/app-shell/typings.json @@ -1,10 +1,13 @@ { - "ambientDevDependencies": { + "globalDevDependencies": { "angular-protractor": "registry:dt/angular-protractor#1.5.0+20160425143459", - "jasmine": "registry:dt/jasmine#2.2.0+20160412134438", + "jasmine": "registry:dt/jasmine#2.2.0+20160505161446", "selenium-webdriver": "registry:dt/selenium-webdriver#2.44.0+20160317120654" }, - "ambientDependencies": { - "es6-shim": "registry:dt/es6-shim#0.31.2+20160317120654" + "globalDependencies": { + "es6-shim": "registry:dt/es6-shim#0.31.2+20160602141504", + "node": "registry:dt/node#6.0.0+20160602155235", + "parse5": "registry:dt/parse5#2.1.5+20160602151856", + "whatwg-fetch": "registry:dt/whatwg-fetch#0.0.0+20160524142046" } }