Skip to content

Commit a5364a4

Browse files
manfredsteyermgechev
authored andcommitted
feat(@angular-devkit/build-angular): write index.html for differential loading
Currently, the IndexHtmlWebpackPlugin generates the index.html file *during* bundling. However, to support differential loading the builder must do this *after* bundling because only then we know the produced files for ES5, ES2015, etc. Hence, this extracts the IndexHtmlWebpackPlugin logic to a helper function which can still be called by IndexHtmlWebpackPlugin for use cases not affected by differential loading (e. g. ng serve) as well as directly by the builder. https://docs.google.com/document/d/13k84oGwrEjwPyAiAjUgaaM7YHJrzYXz7Cbt6CwRp9N4/edit?ts=5c652052
1 parent 50cce66 commit a5364a4

File tree

3 files changed

+307
-171
lines changed

3 files changed

+307
-171
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { createHash } from 'crypto';
10+
import {
11+
RawSource,
12+
ReplaceSource,
13+
Source,
14+
} from 'webpack-sources';
15+
16+
const parse5 = require('parse5');
17+
18+
19+
export type LoadOutputFileFunctionType = (file: string) => string;
20+
21+
export interface GenerateIndexHtmlParams {
22+
// input file name (e. g. index.html)
23+
input: string;
24+
// contents of input
25+
inputContent: string;
26+
baseHref?: string;
27+
deployUrl?: string;
28+
sri: boolean;
29+
// the files emitted by the build
30+
unfilteredSortedFiles: CompiledFileInfo[];
31+
// additional files that should be added using nomodule
32+
noModuleFiles: Set<string>;
33+
// function that loads a file
34+
// This allows us to use different routines within the IndexHtmlWebpackPlugin and
35+
// when used without this plugin.
36+
loadOutputFile: LoadOutputFileFunctionType;
37+
}
38+
39+
/*
40+
* Defines the type of script tag that is generated for the script reference
41+
* nomodule: <script src="..." nomodule></script>
42+
* module: <script src="..." type="module"></script>
43+
* none: <script src="..."></script>
44+
*/
45+
export type CompiledFileType = 'nomodule' | 'module' | 'none';
46+
47+
export interface CompiledFileInfo {
48+
file: string;
49+
type: CompiledFileType;
50+
}
51+
52+
/*
53+
* Helper function used by the IndexHtmlWebpackPlugin.
54+
* Can also be directly used by builder, e. g. in order to generate an index.html
55+
* after processing several configurations in order to build different sets of
56+
* bundles for differential serving.
57+
*/
58+
export function generateIndexHtml(params: GenerateIndexHtmlParams): Source {
59+
60+
const loadOutputFile = params.loadOutputFile;
61+
62+
// Filter files
63+
const existingFiles = new Set<string>();
64+
const stylesheets: string[] = [];
65+
const scripts: string[] = [];
66+
67+
const fileNames = params.unfilteredSortedFiles.map(f => f.file);
68+
const moduleFilesArray = params.unfilteredSortedFiles
69+
.filter(f => f.type === 'module')
70+
.map(f => f.file);
71+
72+
const moduleFiles = new Set<string>(moduleFilesArray);
73+
74+
const noModuleFilesArray = params.unfilteredSortedFiles
75+
.filter(f => f.type === 'nomodule')
76+
.map(f => f.file);
77+
78+
noModuleFilesArray.push(...params.noModuleFiles);
79+
80+
const noModuleFiles = new Set<string>(noModuleFilesArray);
81+
82+
for (const file of fileNames) {
83+
84+
if (existingFiles.has(file)) {
85+
continue;
86+
}
87+
existingFiles.add(file);
88+
89+
if (file.endsWith('.js')) {
90+
scripts.push(file);
91+
} else if (file.endsWith('.css')) {
92+
stylesheets.push(file);
93+
}
94+
}
95+
96+
// Find the head and body elements
97+
const treeAdapter = parse5.treeAdapters.default;
98+
const document = parse5.parse(params.inputContent, { treeAdapter, locationInfo: true });
99+
let headElement;
100+
let bodyElement;
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;
106+
}
107+
if (htmlChild.tagName === 'body') {
108+
bodyElement = htmlChild;
109+
}
110+
}
111+
}
112+
}
113+
114+
if (!headElement || !bodyElement) {
115+
throw new Error('Missing head and/or body elements');
116+
}
117+
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 = params.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 = params.inputContent.indexOf('</head>');
135+
}
136+
137+
// Inject into the html
138+
const indexSource = new ReplaceSource(new RawSource(params.inputContent), params.input);
139+
140+
let scriptElements = '';
141+
for (const script of scripts) {
142+
const attrs: { name: string, value: string | null }[] = [
143+
{ name: 'src', value: (params.deployUrl || '') + script },
144+
];
145+
146+
if (noModuleFiles.has(script)) {
147+
attrs.push({ name: 'nomodule', value: null });
148+
}
149+
150+
if (moduleFiles.has(script)) {
151+
attrs.push({ name: 'type', value: 'module' });
152+
}
153+
154+
if (params.sri) {
155+
const content = loadOutputFile(script);
156+
attrs.push(..._generateSriAttributes(content));
157+
}
158+
159+
const attributes = attrs
160+
.map(attr => attr.value === null ? attr.name : `${attr.name}="${attr.value}"`)
161+
.join(' ');
162+
scriptElements += `<script ${attributes}></script>`;
163+
}
164+
165+
indexSource.insert(
166+
scriptInsertionPoint,
167+
scriptElements,
168+
);
169+
170+
// Adjust base href if specified
171+
if (typeof params.baseHref == 'string') {
172+
let baseElement;
173+
for (const headChild of headElement.childNodes) {
174+
if (headChild.tagName === 'base') {
175+
baseElement = headChild;
176+
}
177+
}
178+
179+
const baseFragment = treeAdapter.createDocumentFragment();
180+
181+
if (!baseElement) {
182+
baseElement = treeAdapter.createElement(
183+
'base',
184+
undefined,
185+
[
186+
{ name: 'href', value: params.baseHref },
187+
],
188+
);
189+
190+
treeAdapter.appendChild(baseFragment, baseElement);
191+
indexSource.insert(
192+
headElement.__location.startTag.endOffset,
193+
parse5.serialize(baseFragment, { treeAdapter }),
194+
);
195+
} else {
196+
let hrefAttribute;
197+
for (const attribute of baseElement.attrs) {
198+
if (attribute.name === 'href') {
199+
hrefAttribute = attribute;
200+
}
201+
}
202+
if (hrefAttribute) {
203+
hrefAttribute.value = params.baseHref;
204+
} else {
205+
baseElement.attrs.push({ name: 'href', value: params.baseHref });
206+
}
207+
208+
treeAdapter.appendChild(baseFragment, baseElement);
209+
indexSource.replace(
210+
baseElement.__location.startOffset,
211+
baseElement.__location.endOffset,
212+
parse5.serialize(baseFragment, { treeAdapter }),
213+
);
214+
}
215+
}
216+
217+
const styleElements = treeAdapter.createDocumentFragment();
218+
for (const stylesheet of stylesheets) {
219+
const attrs = [
220+
{ name: 'rel', value: 'stylesheet' },
221+
{ name: 'href', value: (params.deployUrl || '') + stylesheet },
222+
];
223+
224+
if (params.sri) {
225+
const content = loadOutputFile(stylesheet);
226+
attrs.push(..._generateSriAttributes(content));
227+
}
228+
229+
const element = treeAdapter.createElement('link', undefined, attrs);
230+
treeAdapter.appendChild(styleElements, element);
231+
}
232+
233+
indexSource.insert(
234+
styleInsertionPoint,
235+
parse5.serialize(styleElements, { treeAdapter }),
236+
);
237+
238+
return indexSource;
239+
}
240+
241+
function _generateSriAttributes(content: string) {
242+
const algo = 'sha384';
243+
const hash = createHash(algo)
244+
.update(content, 'utf8')
245+
.digest('base64');
246+
247+
return [
248+
{ name: 'integrity', value: `${algo}-${hash}` },
249+
{ name: 'crossorigin', value: 'anonymous' },
250+
];
251+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { generateIndexHtml } from './generate-index-html';
9+
10+
describe('index-html-webpack-plugin', () => {
11+
12+
it('can generate index.html', () => {
13+
14+
const source = generateIndexHtml({
15+
input: 'index.html',
16+
inputContent: '<html><head></head><body></body></html>',
17+
baseHref: '/',
18+
sri: false,
19+
loadOutputFile: (fileName: string) => '',
20+
unfilteredSortedFiles: [
21+
{file: 'a.js', type: 'module'},
22+
{file: 'b.js', type: 'nomodule'},
23+
{file: 'c.js', type: 'none'},
24+
],
25+
noModuleFiles: new Set<string>(),
26+
});
27+
28+
const html = source.source();
29+
30+
expect(html).toContain('<script src="a.js" type="module"></script>');
31+
expect(html).toContain('<script src="b.js" nomodule></script>');
32+
expect(html).toContain('<script src="c.js"></script>');
33+
34+
});
35+
36+
});

0 commit comments

Comments
 (0)