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

Commit 9cc156d

Browse files
authored
feat(AppShell): add runtime parser (#54)
* chore(app-shell): update to typings 1.0.4 * feat(AppShell): add runtime app shell parser
1 parent f8e0220 commit 9cc156d

34 files changed

+1166
-11
lines changed

.travis.yml

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ before_install:
88

99
install: ./install.sh
1010
script: ./test.sh
11+

app-shell/angular-cli-build.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ module.exports = function(defaults) {
1111
'es6-shim/es6-shim.js',
1212
'reflect-metadata/*.js',
1313
'rxjs/**/*.js',
14-
'@angular/**/*.js'
14+
'@angular/**/*.js',
15+
'parse5/**/*.js'
1516
]
1617
});
1718
};

app-shell/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@angular/platform-browser-dynamic": "2.0.0-rc.0",
2222
"@angular/router": "2.0.0-rc.0",
2323
"es6-shim": "^0.35.0",
24+
"parse5": "https://github.com/mgechev/parse5",
2425
"reflect-metadata": "0.1.3",
2526
"rxjs": "5.0.0-beta.6",
2627
"systemjs": "0.19.26",
@@ -40,6 +41,6 @@
4041
"ts-node": "^0.5.5",
4142
"tslint": "^3.6.0",
4243
"typescript": "^1.8.10",
43-
"typings": "^0.8.1"
44+
"typings": "^1.0.4"
4445
}
4546
}

app-shell/src/app/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ShellNoRender } from './shell-no-render.directive';
55
export * from './is-prerender.service';
66
export * from './shell-no-render.directive';
77
export * from './shell-render.directive';
8+
export * from './shell-parser';
89

910
export const APP_SHELL_DIRECTIVES: Type[] = [
1011
ShellRender,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface ASTAttribute {
2+
name: string;
3+
value: string;
4+
}
5+
6+
export interface ASTNode {
7+
attrs: ASTAttribute[];
8+
childNodes?: ASTNode[];
9+
parentNode?: ASTNode;
10+
nodeName: string;
11+
}
12+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './ast-node';
2+
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export type RouteDefinition = string;
2+
3+
const SHELL_PARSER_CACHE_NAME = 'mobile-toolkit:app-shell';
4+
const APP_SHELL_URL = './app_shell.html';
5+
const NO_RENDER_CSS_SELECTOR = '.shell-no-render';
6+
const ROUTE_DEFINITIONS: RouteDefinition[] = [];
7+
8+
// TODO(mgechev): use if we decide to include @angular/core
9+
// export const SHELL_PARSER_CONFIG = new OpaqueToken('ShellRuntimeParserConfig');
10+
11+
export interface ShellParserConfig {
12+
APP_SHELL_URL?: string;
13+
SHELL_PARSER_CACHE_NAME?: string;
14+
NO_RENDER_CSS_SELECTOR?: string;
15+
ROUTE_DEFINITIONS?: RouteDefinition[];
16+
}
17+
18+
export const SHELL_PARSER_DEFAULT_CONFIG: ShellParserConfig = {
19+
SHELL_PARSER_CACHE_NAME,
20+
APP_SHELL_URL,
21+
NO_RENDER_CSS_SELECTOR,
22+
ROUTE_DEFINITIONS
23+
};
24+
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export abstract class WorkerScope {
2+
abstract fetch(url: string | Request): Promise<Response>;
3+
abstract newRequest(input: string | Request, init?: RequestInit): Request;
4+
abstract newResponse(body?: BodyInit, init?: ResponseInit): Response;
5+
caches: CacheStorage;
6+
}
7+
8+
export class BrowserWorkerScope {
9+
fetch(url: string | Request): Promise<Response> {
10+
return fetch(url);
11+
}
12+
13+
get caches() {
14+
return caches;
15+
}
16+
17+
newRequest(input: string | Request, init?: RequestInit): Request {
18+
return new Request(input, init);
19+
}
20+
21+
newResponse(body?: BodyInit, init?: ResponseInit) {
22+
return new Response(body, init);
23+
}
24+
}
25+
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {ShellParser} from './shell-parser';
2+
import {shellParserFactory} from './shell-parser-factory';
3+
import {RouteDefinition, ShellParserConfig} from './config';
4+
5+
export {
6+
shellParserFactory,
7+
ShellParser,
8+
RouteDefinition,
9+
ShellParserConfig
10+
};
11+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {
2+
beforeEach,
3+
it,
4+
describe,
5+
expect,
6+
inject
7+
} from '@angular/core/testing';
8+
import { ASTNode } from '../ast';
9+
import { CssSelector } from './css-selector';
10+
import { CssNodeMatcher } from './css-node-matcher';
11+
12+
describe('CssNodeMatcher', () => {
13+
14+
var node: ASTNode;
15+
beforeEach(() => {
16+
node = {
17+
attrs: [
18+
{ name: 'foo', value: 'bar' },
19+
{ name: 'class', value: 'dialog modal--drop' },
20+
{ name: 'id', value: 'dialog-id' }
21+
],
22+
nodeName: 'div',
23+
parentNode: null
24+
};
25+
});
26+
27+
describe('successful match', () => {
28+
29+
it('should match any node with empty selector', () => {
30+
const emptySelector = CssSelector.parse('');
31+
const selector = new CssNodeMatcher(emptySelector);
32+
expect(selector.match(node)).toBe(true);
33+
});
34+
35+
it('should match basic element selector', () => {
36+
const elementSelector = CssSelector.parse('div');
37+
const selector = new CssNodeMatcher(elementSelector);
38+
expect(selector.match(node)).toBe(true);
39+
});
40+
41+
it('should match attribute selector', () => {
42+
const attrSelector = CssSelector.parse('[foo=bar]');
43+
const selector = new CssNodeMatcher(attrSelector);
44+
expect(selector.match(node)).toBe(true);
45+
});
46+
47+
it('should match attribute selector when no value is provided', () => {
48+
const attrSelector = CssSelector.parse('[foo]');
49+
const selector = new CssNodeMatcher(attrSelector);
50+
expect(selector.match(node)).toBe(true);
51+
});
52+
53+
it('should match class selector', () => {
54+
const classSelector = CssSelector.parse('.dialog');
55+
const selector = new CssNodeMatcher(classSelector);
56+
expect(selector.match(node)).toBe(true);
57+
const complexClassSelector = CssSelector.parse('.dialog.modal--drop');
58+
const complexSelector = new CssNodeMatcher(complexClassSelector);
59+
expect(complexSelector.match(node)).toBe(true);
60+
});
61+
62+
it('should match element by id', () => {
63+
const idSelector = CssSelector.parse('#dialog-id');
64+
const selector = new CssNodeMatcher(idSelector);
65+
expect(selector.match(node)).toBe(true);
66+
});
67+
68+
});
69+
70+
describe('unsuccessful match', () => {
71+
72+
it('should fail when different element is used', () => {
73+
const elementSelector = CssSelector.parse('span');
74+
const selector = new CssNodeMatcher(elementSelector);
75+
expect(selector.match(node)).toBe(false);
76+
});
77+
78+
it('should fail when different attribute selector is provided', () => {
79+
const attrSelector = CssSelector.parse('[foo=qux]');
80+
const selector = new CssNodeMatcher(attrSelector);
81+
expect(selector.match(node)).toBe(false);
82+
});
83+
84+
it('should fail when non-matching class selector is used', () => {
85+
const classSelector = CssSelector.parse('.modal');
86+
const selector = new CssNodeMatcher(classSelector);
87+
expect(selector.match(node)).toBe(false);
88+
const complexClassSelector = CssSelector.parse('.dialog.modal-drop');
89+
const complexSelector = new CssNodeMatcher(complexClassSelector);
90+
expect(complexSelector.match(node)).toBe(false);
91+
});
92+
93+
it('should fail when superset of attributes is used in selector', () => {
94+
const cssSelector = CssSelector.parse('[foo=bar][baz=bar]');
95+
const selector = new CssNodeMatcher(cssSelector);
96+
expect(selector.match(node)).toBe(false);
97+
});
98+
99+
it('should fail when superset of attributes is used in selector', () => {
100+
const cssSelector = CssSelector.parse('[no-render]');
101+
const selector = new CssNodeMatcher(cssSelector);
102+
expect(selector.match(node)).toBe(false);
103+
})
104+
105+
it('should fail match by id when element has no id', () => {
106+
const cssSelector = CssSelector.parse('#foo');
107+
const selector = new CssNodeMatcher(cssSelector);
108+
const node1: ASTNode = {
109+
nodeName: 'div',
110+
parentNode: null,
111+
attrs: []
112+
};
113+
const node2: ASTNode = {
114+
attrs: [
115+
{ name: 'not-id', value: '' }
116+
],
117+
nodeName: 'div',
118+
parentNode: null
119+
};
120+
expect(selector.match(node1)).toBe(false);
121+
expect(selector.match(node2)).toBe(false);
122+
});
123+
124+
});
125+
});
126+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {NodeMatcher} from './node-matcher';
2+
import {ASTNode, ASTAttribute} from '../ast';
3+
import {CssSelector} from './css-selector';
4+
5+
export const cssNodeMatcherFactory = (selector: string) => {
6+
return new CssNodeMatcher(CssSelector.parse(selector));
7+
};
8+
9+
export class CssNodeMatcher extends NodeMatcher {
10+
constructor(private selector: CssSelector) {
11+
super();
12+
}
13+
14+
match(node: ASTNode): boolean {
15+
return this.matchElement(node) && this.matchAttributes(node) &&
16+
this.matchId(node) && this.matchClassNames(node);
17+
}
18+
19+
private matchElement(node: ASTNode) {
20+
return !this.selector.element || this.selector.element === node.nodeName;
21+
}
22+
23+
private matchAttributes(node: ASTNode) {
24+
const selectorAttrs = this.selector.attrs;
25+
const nodeAttrs = (node.attrs || []).reduce((accum: any, attr: ASTAttribute) => {
26+
accum[attr.name] = attr.value;
27+
return accum;
28+
}, {});
29+
const selectorAttrNames = Object.keys(selectorAttrs);
30+
if (!selectorAttrNames.length) {
31+
return true;
32+
}
33+
return selectorAttrNames.reduce((accum: boolean, name: string) => {
34+
return accum && (selectorAttrs[name] === nodeAttrs[name] ||
35+
// nodeAttrs[name] cannot be undefined after parsing
36+
// since it'll be normalized to name="" if empty
37+
(nodeAttrs[name] !== undefined && selectorAttrs[name] === ''));
38+
}, true);
39+
}
40+
41+
private matchClassNames(node: ASTNode) {
42+
const selectorClasses = this.selector.classNames;
43+
if (!selectorClasses.length) {
44+
return true;
45+
}
46+
const classAttr = this.getAttribute(node, 'class');
47+
// We have selector by class but we don't have class attribute of the node
48+
if (!classAttr) {
49+
return false;
50+
}
51+
const classMap = classAttr.value.split(' ')
52+
.reduce((accum: any, val: string) => {
53+
accum[val] = true;
54+
return accum;
55+
}, {});
56+
return selectorClasses.reduce((accum: boolean, val: string) => {
57+
return accum && !!classMap[val];
58+
}, true);
59+
}
60+
61+
private matchId(node: ASTNode) {
62+
const id = this.selector.elementId;
63+
if (!id) {
64+
return true;
65+
}
66+
const idAttr = this.getAttribute(node, 'id');
67+
if (idAttr && idAttr.value === this.selector.elementId) {
68+
return true;
69+
} else {
70+
return false;
71+
}
72+
}
73+
74+
private getAttribute(node: ASTNode, attrName: string) {
75+
return (node.attrs || [])
76+
.filter((attr: ASTAttribute) =>
77+
attr.name === attrName).pop();
78+
}
79+
}
80+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
beforeEachProviders,
3+
it,
4+
describe,
5+
expect,
6+
inject
7+
} from '@angular/core/testing';
8+
import { CssSelector } from './css-selector';
9+
10+
describe('CssSelector', () => {
11+
12+
it('should support id selectors', () => {
13+
const result = CssSelector.parse('#elemId');
14+
expect(result.elementId).toBe('elemId');
15+
});
16+
17+
it('should support element selectors', () => {
18+
const result = CssSelector.parse('div');
19+
expect(result.element).toBe('div');
20+
});
21+
22+
it('should support class selectors', () => {
23+
const result = CssSelector.parse('.foo');
24+
expect(result.classNames).toBeTruthy();
25+
expect(result.classNames.length).toBe(1);
26+
expect(result.classNames[0]).toBe('foo');
27+
});
28+
29+
describe('attribute selectors', () => {
30+
31+
it('should support attributes with no values', () => {
32+
const result = CssSelector.parse('[title]');
33+
expect(result.attrs['title']).toBe('');
34+
});
35+
36+
it('should support attributes with values', () => {
37+
const result = CssSelector.parse('[title=random title]');
38+
expect(result.attrs['title']).toBe('random title');
39+
});
40+
41+
});
42+
43+
describe('complex selectors', () => {
44+
45+
it('should support complex selectors', () => {
46+
const result = CssSelector.parse('div.foo[attr]');
47+
expect(result.element).toBe('div');
48+
expect(result.classNames[0]).toBe('foo');
49+
expect(result.attrs['attr']).toBe('');
50+
});
51+
52+
it('should support complex selectors with terminals in random order', () => {
53+
const result = CssSelector.parse('.foo[attr=bar].qux#baz');
54+
expect(result.element).toBe(null);
55+
expect(result.attrs['attr']).toBe('bar');
56+
expect(result.elementId).toBe('baz');
57+
expect(result.classNames).toEqual(['foo', 'qux']);
58+
});
59+
60+
});
61+
});
62+

0 commit comments

Comments
 (0)