Skip to content
This repository was archived by the owner on Apr 4, 2025. It is now read-only.

Commit a820750

Browse files
clydinfilipesilva
authored andcommitted
fix(@angular-devkit/build-angular): perform surgical updates to index.html
Fixes: angular/angular-cli#10696
1 parent 5c442a9 commit a820750

File tree

3 files changed

+134
-46
lines changed

3 files changed

+134
-46
lines changed

packages/angular_devkit/build_angular/src/angular-cli-files/plugins/index-html-webpack-plugin.ts

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { createHash } from 'crypto';
99
import { Compiler, compilation } from 'webpack';
10-
import { RawSource } from 'webpack-sources';
10+
import { RawSource, ReplaceSource } from 'webpack-sources';
1111

1212
const parse5 = require('parse5');
1313

@@ -95,71 +95,94 @@ export class IndexHtmlWebpackPlugin {
9595

9696
// Find the head and body elements
9797
const treeAdapter = parse5.treeAdapters.default;
98-
const document = parse5.parse(inputContent, { treeAdapter });
98+
const document = parse5.parse(inputContent, { treeAdapter, locationInfo: true });
9999
let headElement;
100100
let bodyElement;
101-
for (const topNode of document.childNodes) {
102-
if (topNode.tagName === 'html') {
103-
for (const htmlNode of topNode.childNodes) {
104-
if (htmlNode.tagName === 'head') {
105-
headElement = htmlNode;
101+
for (const docChild of document.childNodes) {
102+
if (docChild.tagName === 'html') {
103+
for (const htmlChild of docChild.childNodes) {
104+
if (htmlChild.tagName === 'head') {
105+
headElement = htmlChild;
106106
}
107-
if (htmlNode.tagName === 'body') {
108-
bodyElement = htmlNode;
107+
if (htmlChild.tagName === 'body') {
108+
bodyElement = htmlChild;
109109
}
110110
}
111111
}
112112
}
113113

114-
// Inject into the html
115-
116114
if (!headElement || !bodyElement) {
117115
throw new Error('Missing head and/or body elements');
118116
}
119117

118+
// Determine script insertion point
119+
let scriptInsertionPoint;
120+
if (bodyElement.__location && bodyElement.__location.endTag) {
121+
scriptInsertionPoint = bodyElement.__location.endTag.startOffset;
122+
} else {
123+
// Less accurate fallback
124+
// parse5 4.x does not provide locations if malformed html is present
125+
scriptInsertionPoint = inputContent.indexOf('</body>');
126+
}
127+
128+
let styleInsertionPoint;
129+
if (headElement.__location && headElement.__location.endTag) {
130+
styleInsertionPoint = headElement.__location.endTag.startOffset;
131+
} else {
132+
// Less accurate fallback
133+
// parse5 4.x does not provide locations if malformed html is present
134+
styleInsertionPoint = inputContent.indexOf('</head>');
135+
}
136+
137+
// Inject into the html
138+
const indexSource = new ReplaceSource(new RawSource(inputContent), this._options.input);
139+
140+
const scriptElements = treeAdapter.createDocumentFragment();
120141
for (const script of scripts) {
121142
const attrs = [
122143
{ name: 'type', value: 'text/javascript' },
123144
{ name: 'src', value: (this._options.deployUrl || '') + script },
124145
];
146+
125147
if (this._options.sri) {
126-
const algo = 'sha384';
127-
const hash = createHash(algo)
128-
.update(compilation.assets[script].source(), 'utf8')
129-
.digest('base64');
130-
attrs.push(
131-
{ name: 'integrity', value: `${algo}-${hash}` },
132-
{ name: 'crossorigin', value: 'anonymous' },
133-
);
148+
const content = compilation.assets[script].source();
149+
attrs.push(...this._generateSriAttributes(content));
134150
}
135151

136-
const element = treeAdapter.createElement(
137-
'script',
138-
undefined,
139-
attrs,
140-
);
141-
treeAdapter.appendChild(bodyElement, element);
152+
const element = treeAdapter.createElement('script', undefined, attrs);
153+
treeAdapter.appendChild(scriptElements, element);
142154
}
143155

156+
indexSource.insert(
157+
scriptInsertionPoint,
158+
parse5.serialize(scriptElements, { treeAdapter }),
159+
);
160+
144161
// Adjust base href if specified
145-
if (this._options.baseHref != undefined) {
162+
if (typeof this._options.baseHref == 'string') {
146163
let baseElement;
147-
for (const node of headElement.childNodes) {
148-
if (node.tagName === 'base') {
149-
baseElement = node;
150-
break;
164+
for (const headChild of headElement.childNodes) {
165+
if (headChild.tagName === 'base') {
166+
baseElement = headChild;
151167
}
152168
}
153169

170+
const baseFragment = treeAdapter.createDocumentFragment();
171+
154172
if (!baseElement) {
155-
const element = treeAdapter.createElement(
173+
baseElement = treeAdapter.createElement(
156174
'base',
157175
undefined,
158176
[
159177
{ name: 'href', value: this._options.baseHref },
160178
],
161179
);
162-
treeAdapter.appendChild(headElement, element);
180+
181+
treeAdapter.appendChild(baseFragment, baseElement);
182+
indexSource.insert(
183+
headElement.__location.startTag.endOffset + 1,
184+
parse5.serialize(baseFragment, { treeAdapter }),
185+
);
163186
} else {
164187
let hrefAttribute;
165188
for (const attribute of baseElement.attrs) {
@@ -172,24 +195,51 @@ export class IndexHtmlWebpackPlugin {
172195
} else {
173196
baseElement.attrs.push({ name: 'href', value: this._options.baseHref });
174197
}
198+
199+
treeAdapter.appendChild(baseFragment, baseElement);
200+
indexSource.replace(
201+
baseElement.__location.startOffset,
202+
baseElement.__location.endOffset,
203+
parse5.serialize(baseFragment, { treeAdapter }),
204+
);
175205
}
176206
}
177207

208+
const styleElements = treeAdapter.createDocumentFragment();
178209
for (const stylesheet of stylesheets) {
179-
const element = treeAdapter.createElement(
180-
'link',
181-
undefined,
182-
[
183-
{ name: 'rel', value: 'stylesheet' },
184-
{ name: 'href', value: (this._options.deployUrl || '') + stylesheet },
185-
],
186-
);
187-
treeAdapter.appendChild(headElement, element);
210+
const attrs = [
211+
{ name: 'rel', value: 'stylesheet' },
212+
{ name: 'href', value: (this._options.deployUrl || '') + stylesheet },
213+
];
214+
215+
if (this._options.sri) {
216+
const content = compilation.assets[stylesheet].source();
217+
attrs.push(...this._generateSriAttributes(content));
218+
}
219+
220+
const element = treeAdapter.createElement('link', undefined, attrs);
221+
treeAdapter.appendChild(styleElements, element);
188222
}
189223

224+
indexSource.insert(
225+
styleInsertionPoint,
226+
parse5.serialize(styleElements, { treeAdapter }),
227+
);
228+
190229
// Add to compilation assets
191-
const outputContent = parse5.serialize(document, { treeAdapter });
192-
compilation.assets[this._options.output] = new RawSource(outputContent);
230+
compilation.assets[this._options.output] = indexSource;
193231
});
194232
}
233+
234+
private _generateSriAttributes(content: string) {
235+
const algo = 'sha384';
236+
const hash = createHash(algo)
237+
.update(content, 'utf8')
238+
.digest('base64');
239+
240+
return [
241+
{ name: 'integrity', value: `${algo}-${hash}` },
242+
{ name: 'crossorigin', value: 'anonymous' },
243+
];
244+
}
195245
}

packages/angular_devkit/build_angular/test/browser/index-bom_spec_large.ts renamed to packages/angular_devkit/build_angular/test/browser/index_spec_large.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { join, normalize, virtualFs } from '@angular-devkit/core';
9+
import { join, normalize, tags, virtualFs } from '@angular-devkit/core';
1010
import { tap } from 'rxjs/operators';
1111
import { Timeout, browserTargetSpec, host, runTargetSpec } from '../utils';
1212

@@ -52,4 +52,42 @@ describe('Browser Builder works with BOM index.html', () => {
5252
}),
5353
).subscribe(undefined, done.fail, done);
5454
}, Timeout.Basic);
55+
56+
it('keeps escaped charaters', (done) => {
57+
host.writeMultipleFiles({
58+
'src/index.html': tags.oneLine`
59+
<html><head><title>&iacute;</title><base href="/"></head>
60+
<body><app-root></app-root></body></html>
61+
`,
62+
});
63+
64+
runTargetSpec(host, browserTargetSpec).pipe(
65+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
66+
tap(() => {
67+
const fileName = join(outputPath, 'index.html');
68+
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
69+
// tslint:disable-next-line:max-line-length
70+
expect(content).toBe(`<html><head><title>&iacute;</title><base href="/"></head> <body><app-root></app-root><script type="text/javascript" src="runtime.js"></script><script type="text/javascript" src="polyfills.js"></script><script type="text/javascript" src="styles.js"></script><script type="text/javascript" src="vendor.js"></script><script type="text/javascript" src="main.js"></script></body></html>`);
71+
}),
72+
).subscribe(undefined, done.fail, done);
73+
}, Timeout.Basic);
74+
75+
it('keeps custom template charaters', (done) => {
76+
host.writeMultipleFiles({
77+
'src/index.html': tags.oneLine`
78+
<html><head><base href="/"><%= csrf_meta_tags %></head>
79+
<body><app-root></app-root></body></html>
80+
`,
81+
});
82+
83+
runTargetSpec(host, browserTargetSpec).pipe(
84+
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
85+
tap(() => {
86+
const fileName = join(outputPath, 'index.html');
87+
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
88+
// tslint:disable-next-line:max-line-length
89+
expect(content).toBe(`<html><head><base href="/"><%= csrf_meta_tags %></head> <body><app-root></app-root><script type="text/javascript" src="runtime.js"></script><script type="text/javascript" src="polyfills.js"></script><script type="text/javascript" src="styles.js"></script><script type="text/javascript" src="vendor.js"></script><script type="text/javascript" src="main.js"></script></body></html>`);
90+
}),
91+
).subscribe(undefined, done.fail, done);
92+
}, Timeout.Basic);
5593
});

packages/angular_devkit/build_angular/test/browser/service-worker_spec_large.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe('Browser Builder', () => {
9898
hashTable: {
9999
'/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
100100
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
101-
'/index.html': '3e659d6e536916b7d178d02a2e6e5492f868bf68',
101+
'/index.html': '843c96f0aeadc8f093b1b2203c08891ecd8f7425',
102102
},
103103
});
104104
}),
@@ -153,7 +153,7 @@ describe('Browser Builder', () => {
153153
hashTable: {
154154
'/foo/bar/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
155155
'/foo/bar/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
156-
'/foo/bar/index.html': '5b53fa9e07e4111b8ef84613fb989a56fee502b0',
156+
'/foo/bar/index.html': '9ef50361678004b3b197c12cbc74962e5a15b844',
157157
},
158158
});
159159
}),

0 commit comments

Comments
 (0)