Skip to content
This repository was archived by the owner on Oct 12, 2021. It is now read-only.

feat(AppShell): add runtime parser #54

Merged
merged 2 commits into from
Jun 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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

3 changes: 2 additions & 1 deletion app-shell/angular-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ module.exports = function(defaults) {
'es6-shim/es6-shim.js',
'reflect-metadata/*.js',
'rxjs/**/*.js',
'@angular/**/*.js'
'@angular/**/*.js',
'parse5/**/*.js'
]
});
};
3 changes: 2 additions & 1 deletion app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
1 change: 1 addition & 0 deletions app-shell/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions app-shell/src/app/shell-parser/ast/ast-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface ASTAttribute {
name: string;
value: string;
}

export interface ASTNode {
attrs: ASTAttribute[];
childNodes?: ASTNode[];
parentNode?: ASTNode;
nodeName: string;
}

2 changes: 2 additions & 0 deletions app-shell/src/app/shell-parser/ast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ast-node';

24 changes: 24 additions & 0 deletions app-shell/src/app/shell-parser/config.ts
Original file line number Diff line number Diff line change
@@ -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
};

25 changes: 25 additions & 0 deletions app-shell/src/app/shell-parser/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export abstract class WorkerScope {
abstract fetch(url: string | Request): Promise<Response>;
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<Response> {
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);
}
}

11 changes: 11 additions & 0 deletions app-shell/src/app/shell-parser/index.ts
Original file line number Diff line number Diff line change
@@ -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
};

126 changes: 126 additions & 0 deletions app-shell/src/app/shell-parser/node-matcher/css-node-matcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});

});
});

80 changes: 80 additions & 0 deletions app-shell/src/app/shell-parser/node-matcher/css-node-matcher.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}

Original file line number Diff line number Diff line change
@@ -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']);
});

});
});

Loading