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

Commit a7d2d6e

Browse files
mgechevjeffbcross
authored andcommitted
feat(AppShell): add resource inline functionality (#78)
1. Allow visitors to process the AST produced by the template before serialization. 2. Implement inlining of external resources in stylesheets and style attributes. 3. Refactor the node stripping to use the visitors API.
1 parent 6bd212a commit a7d2d6e

17 files changed

+611
-28
lines changed

app-shell/src/app/shell-parser/ast/ast-node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export interface ASTNode {
88
childNodes?: ASTNode[];
99
parentNode?: ASTNode;
1010
nodeName: string;
11+
value?: string;
1112
}
1213

app-shell/src/app/shell-parser/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const SHELL_PARSER_CACHE_NAME = 'mobile-toolkit:app-shell';
44
const APP_SHELL_URL = './app_shell.html';
55
const NO_RENDER_CSS_SELECTOR = '[shellNoRender]';
66
const ROUTE_DEFINITIONS: RouteDefinition[] = [];
7+
const INLINE_IMAGES: string[] = ['png', 'svg', 'jpg'];
78

89
// TODO(mgechev): use if we decide to include @angular/core
910
// export const SHELL_PARSER_CONFIG = new OpaqueToken('ShellRuntimeParserConfig');
@@ -13,12 +14,14 @@ export interface ShellParserConfig {
1314
SHELL_PARSER_CACHE_NAME?: string;
1415
NO_RENDER_CSS_SELECTOR?: string;
1516
ROUTE_DEFINITIONS?: RouteDefinition[];
17+
INLINE_IMAGES?: string[];
1618
}
1719

1820
export const SHELL_PARSER_DEFAULT_CONFIG: ShellParserConfig = {
1921
SHELL_PARSER_CACHE_NAME,
2022
APP_SHELL_URL,
2123
NO_RENDER_CSS_SELECTOR,
22-
ROUTE_DEFINITIONS
24+
ROUTE_DEFINITIONS,
25+
INLINE_IMAGES
2326
};
2427

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './node-visitor';
2+
export * from './resource-inline';
3+
export * from './template-strip-visitor';
4+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {ASTNode} from '../ast';
2+
3+
export abstract class NodeVisitor {
4+
5+
abstract process(node: ASTNode): Promise<ASTNode>;
6+
7+
visit(currentNode: ASTNode): Promise<ASTNode> {
8+
return this.process(currentNode)
9+
.then((node: ASTNode) => {
10+
if (node) {
11+
return Promise
12+
.all((node.childNodes || [])
13+
.slice()
14+
.map(this.visit.bind(this)))
15+
.then(() => node);
16+
} else {
17+
return null;
18+
}
19+
})
20+
}
21+
22+
}
23+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './resource-inline-visitor';
2+
export * from './stylesheet-resource-inline-visitor';
3+
export * from './inline-style-resource-inline-visitor';
4+
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
beforeEach,
3+
it,
4+
describe,
5+
expect,
6+
inject
7+
} from '@angular/core/testing';
8+
9+
import {ASTNode} from '../../ast';
10+
import {MockWorkerScope, MockResponse} from '../../testing';
11+
import {InlineStyleResourceInlineVisitor} from './';
12+
13+
const createResponseHelper = (body: string, contentType: string) => {
14+
const response = new MockResponse(body);
15+
response.headers = {
16+
get(name: string) {
17+
if (name === 'content-type') {
18+
return contentType;
19+
}
20+
}
21+
};
22+
return response;
23+
};
24+
25+
describe('ResourceInlineVisitor', () => {
26+
27+
let simpleNode: ASTNode;
28+
let nestedAst: ASTNode;
29+
let multiStyles: ASTNode;
30+
let jpgInlineVisitor: InlineStyleResourceInlineVisitor;
31+
let pngJpgInlineVisitor: InlineStyleResourceInlineVisitor;
32+
let scope: MockWorkerScope;
33+
34+
beforeEach(() => {
35+
scope = new MockWorkerScope();
36+
jpgInlineVisitor = new InlineStyleResourceInlineVisitor(scope, ['jpg']);
37+
pngJpgInlineVisitor = new InlineStyleResourceInlineVisitor(scope, ['png', 'jpg']);
38+
simpleNode = {
39+
attrs: [{
40+
name: 'style',
41+
value: 'color: #fff; background-image: url(bar.jpg);'
42+
}],
43+
nodeName: 'div',
44+
};
45+
multiStyles = {
46+
attrs: [{
47+
name: 'style',
48+
value: 'color: #fff; background-image: url(bar.jpg);'
49+
}, {
50+
name: 'style',
51+
value: 'color: #fff; background-image: url(baz.jpg);'
52+
}],
53+
nodeName: 'div',
54+
};
55+
nestedAst = {
56+
nodeName: 'body',
57+
attrs: null,
58+
childNodes: [
59+
{
60+
nodeName: 'div',
61+
attrs: null,
62+
childNodes: [
63+
{
64+
nodeName: 'p',
65+
attrs: null,
66+
childNodes: [
67+
{
68+
nodeName: 'span',
69+
attrs: [{
70+
name: 'style',
71+
value: 'color: #fff; background-image: url(bar.jpg);'
72+
}],
73+
}
74+
]
75+
}
76+
]
77+
},
78+
{
79+
nodeName: 'section',
80+
attrs: null,
81+
childNodes: [
82+
{
83+
nodeName: 'span',
84+
attrs: [{
85+
name: 'style',
86+
value: 'font-size: 42px; background-image: url(bar.png);'
87+
}]
88+
}
89+
]
90+
}
91+
]
92+
};
93+
});
94+
95+
it('should inline assets in style attribute', (done: any) => {
96+
scope.mockResponses['bar.jpg'] = createResponseHelper('bar', 'image/jpg');
97+
jpgInlineVisitor.visit(simpleNode)
98+
.then(() => {
99+
expect(simpleNode.attrs[0].value).toBe('color: #fff; background-image: url(data:image/jpg;base64,YgBhAHIA);');
100+
done();
101+
});
102+
});
103+
104+
it('should work with nested elements', (done: any) => {
105+
scope.mockResponses['bar.jpg'] = createResponseHelper('bar', 'image/jpg');
106+
scope.mockResponses['bar.png'] = createResponseHelper('bar', 'image/png');
107+
pngJpgInlineVisitor.visit(nestedAst)
108+
.then(() => {
109+
expect(nestedAst.childNodes[0].childNodes[0].childNodes[0].attrs[0].value).toBe('color: #fff; background-image: url(data:image/jpg;base64,YgBhAHIA);');
110+
expect(nestedAst.childNodes[1].childNodes[0].attrs[0].value).toBe('font-size: 42px; background-image: url(data:image/png;base64,YgBhAHIA);');
111+
done();
112+
});
113+
});
114+
115+
it('should always pick the last occurence of the style attribute', (done: any) => {
116+
scope.mockResponses['bar.jpg'] = createResponseHelper('bar', 'image/jpg');
117+
scope.mockResponses['baz.jpg'] = createResponseHelper('baz', 'image/jpg');
118+
jpgInlineVisitor.visit(multiStyles)
119+
.then(() => {
120+
expect(multiStyles.attrs[0].value).toBe('color: #fff; background-image: url(bar.jpg);');
121+
expect(multiStyles.attrs[1].value).toBe('color: #fff; background-image: url(data:image/jpg;base64,YgBhAHoA);');
122+
done();
123+
});
124+
});
125+
126+
});
127+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {ASTNode, ASTAttribute} from '../../ast';
2+
import {ResourceInlineVisitor} from './resource-inline-visitor';
3+
import {WorkerScope} from '../../context';
4+
5+
const URL_REGEXP = /:\s+url\(['"]?(.*?)['"]?\)/gmi;
6+
7+
export class InlineStyleResourceInlineVisitor extends ResourceInlineVisitor {
8+
9+
process(node: ASTNode): Promise<ASTNode> {
10+
const styleAttr = (node.attrs || [])
11+
.filter((a: ASTAttribute) => a.name === 'style')
12+
.pop();
13+
if (styleAttr) {
14+
return this.inlineAssets(styleAttr.value)
15+
.then((content: string) => {
16+
styleAttr.value = content;
17+
return node;
18+
});
19+
}
20+
return Promise.resolve(node);
21+
}
22+
23+
}
24+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {ASTNode} from '../../ast';
2+
import {NodeVisitor} from '../node-visitor';
3+
import {WorkerScope} from '../../context';
4+
5+
const URL_REGEXP = /:\s+url\(['"]?(.*?)['"]?\)/gmi;
6+
7+
export abstract class ResourceInlineVisitor extends NodeVisitor {
8+
9+
constructor(private scope: WorkerScope, private inlineExtensions: string[]) {
10+
super();
11+
}
12+
13+
inlineAssets(style: string) {
14+
let urls = this.getImagesUrls(style);
15+
urls = urls.filter((url: string, idx: number) => urls.indexOf(url) === idx);
16+
return this.processInline(urls, style)
17+
.then((content: string) => content);
18+
}
19+
20+
protected getImagesUrls(styles: string): string[] {
21+
URL_REGEXP.lastIndex = 0;
22+
let match: string[];
23+
const result: string[] = [];
24+
while ((match = URL_REGEXP.exec(styles)) !== null) {
25+
const url = match[1];
26+
if (this.supportedExtension(url)) {
27+
result.push(url);
28+
}
29+
}
30+
return result;
31+
}
32+
33+
private supportedExtension(url: string) {
34+
return this.inlineExtensions.some((ext: string) => new RegExp(`${ext}$`).test(url));
35+
}
36+
37+
protected processInline(urls: string[], styles: string): Promise<string> {
38+
const processResponse = (response: Response): Promise<string[]> => {
39+
if (response && response.ok) {
40+
return response.arrayBuffer()
41+
.then((arr: ArrayBuffer) => [
42+
btoa(String.fromCharCode.apply(null, new Uint8Array(arr))),
43+
response.headers.get('content-type')
44+
]);
45+
} else {
46+
return null;
47+
}
48+
};
49+
return Promise.all(urls.map((url: string) => this.scope.fetch(url).catch(() => null)))
50+
.then((responses: any[]) => <any>Promise.all(responses.map(processResponse)))
51+
.then((images: string[][]) => {
52+
return images.map((img: string[]) => img ? `data:${img[1]};base64,${img[0]}` : null)
53+
.reduce((content: string, img: string, idx: number) =>
54+
img ? content.replace(new RegExp(urls[idx], 'g'), img) : content, styles);
55+
});
56+
}
57+
58+
}
59+

0 commit comments

Comments
 (0)