Skip to content
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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@types/node": "10.12.30",
"@types/node-fetch": "^2.1.6",
"@types/npm-package-arg": "^6.1.0",
"@types/parse5-html-rewriting-stream": "^5.1.2",
"@types/pidusage": "^2.0.1",
"@types/progress": "^2.0.3",
"@types/request": "^2.47.1",
Expand Down Expand Up @@ -180,9 +181,7 @@
"open": "7.3.0",
"ora": "5.1.0",
"pacote": "11.1.4",
"parse5": "6.0.1",
"parse5-html-rewriting-stream": "6.0.1",
"parse5-htmlparser2-tree-adapter": "6.0.1",
"pidtree": "^0.5.0",
"pidusage": "^2.0.17",
"pnp-webpack-plugin": "1.6.4",
Expand Down
3 changes: 2 additions & 1 deletion packages/angular/pwa/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ ts_library(
"//packages/angular_devkit/schematics",
"//packages/schematics/angular",
"@npm//@types/node",
"@npm//parse5-html-rewriting-stream",
"@npm//@types/parse5-html-rewriting-stream",
"@npm//rxjs",
],
)
Expand All @@ -60,6 +60,7 @@ ts_library(
deps = [
":pwa",
"//packages/angular_devkit/schematics/testing",
"@npm//parse5-html-rewriting-stream",
],
)

Expand Down
10 changes: 4 additions & 6 deletions packages/angular/pwa/pwa/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,24 @@ import { getWorkspace, updateWorkspace } from '@schematics/angular/utility/works
import { Readable, Writable } from 'stream';
import { Schema as PwaOptions } from './schema';

const RewritingStream = require('parse5-html-rewriting-stream');

function updateIndexFile(path: string): Rule {
return (host: Tree) => {
return async (host: Tree) => {
const buffer = host.read(path);
if (buffer === null) {
throw new SchematicsException(`Could not read index file: ${path}`);
}

const rewriter = new RewritingStream();
const rewriter = new (await import('parse5-html-rewriting-stream'))();
let needsNoScript = true;
rewriter.on('startTag', (startTag: { tagName: string }) => {
rewriter.on('startTag', startTag => {
if (startTag.tagName === 'noscript') {
needsNoScript = false;
}

rewriter.emitStartTag(startTag);
});

rewriter.on('endTag', (endTag: { tagName: string }) => {
rewriter.on('endTag', endTag => {
if (endTag.tagName === 'head') {
rewriter.emitRaw(' <link rel="manifest" href="manifest.webmanifest">\n');
rewriter.emitRaw(' <meta name="theme-color" content="#1976d2">\n');
Expand Down
4 changes: 2 additions & 2 deletions packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ ts_library(
"@npm//@types/loader-utils",
"@npm//@types/minimatch",
"@npm//@types/node",
"@npm//@types/parse5-html-rewriting-stream",
"@npm//@types/rimraf",
"@npm//@types/semver",
"@npm//@types/speed-measure-webpack-plugin",
Expand Down Expand Up @@ -160,8 +161,7 @@ ts_library(
"@npm//ng-packagr",
"@npm//open",
"@npm//ora",
"@npm//parse5",
"@npm//parse5-htmlparser2-tree-adapter",
"@npm//parse5-html-rewriting-stream",
"@npm//pnp-webpack-plugin",
"@npm//postcss",
"@npm//postcss-import",
Expand Down
3 changes: 1 addition & 2 deletions packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@
"minimatch": "3.0.4",
"open": "7.3.0",
"ora": "5.1.0",
"parse5": "6.0.1",
"parse5-htmlparser2-tree-adapter": "6.0.1",
"parse5-html-rewriting-stream": "6.0.1",
"pnp-webpack-plugin": "1.6.4",
"postcss": "7.0.32",
"postcss-import": "12.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
*/

import { createHash } from 'crypto';
import { RawSource, ReplaceSource } from 'webpack-sources';

const parse5 = require('parse5');
const treeAdapter = require('parse5-htmlparser2-tree-adapter');
import { htmlRewritingStream } from './html-rewriting-stream';

export type LoadOutputFileFunctionType = (file: string) => Promise<string>;

Expand Down Expand Up @@ -59,12 +56,14 @@ export interface FileInfo {
* after processing several configurations in order to build different sets of
* bundles for differential serving.
*/
// tslint:disable-next-line: no-big-function
export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise<string> {
const { loadOutputFile, files, noModuleFiles = [], moduleFiles = [], entrypoints } = params;
const {
loadOutputFile, files, noModuleFiles = [], moduleFiles = [], entrypoints,
sri, deployUrl = '', lang, baseHref, inputContent,
} = params;

let { crossOrigin = 'none' } = params;
if (params.sri && crossOrigin === 'none') {
if (sri && crossOrigin === 'none') {
crossOrigin = 'anonymous';
}

Expand All @@ -90,33 +89,12 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise
}
}

// Find the head and body elements
const document = parse5.parse(params.inputContent, {
treeAdapter,
sourceCodeLocationInfo: true,
});

// tslint:disable: no-any
const htmlElement = document.children.find((c: any) => c.name === 'html');
const headElement = htmlElement.children.find((c: any) => c.name === 'head');
const bodyElement = htmlElement.children.find((c: any) => c.name === 'body');
// tslint:enable: no-any

if (!headElement || !bodyElement) {
throw new Error('Missing head and/or body elements');
}

// Inject into the html
const indexSource = new ReplaceSource(new RawSource(params.inputContent), params.input);

const scriptsElements = treeAdapter.createDocumentFragment();
const scriptTags: string[] = [];
for (const script of scripts) {
const attrs: { name: string; value: string }[] = [
{ name: 'src', value: (params.deployUrl || '') + script },
];
const attrs = [`src="${deployUrl}${script}"`];

if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
attrs.push(`crossorigin="${crossOrigin}"`);
}

// We want to include nomodule or module when a file is not common amongs all
Expand All @@ -130,111 +108,115 @@ export async function augmentIndexHtml(params: AugmentIndexHtmlOptions): Promise
const isModuleType = moduleFiles.some(scriptPredictor);

if (isNoModuleType && !isModuleType) {
attrs.push(
{ name: 'nomodule', value: '' },
{ name: 'defer', value: '' },
);
attrs.push('nomodule', 'defer');
} else if (isModuleType && !isNoModuleType) {
attrs.push({ name: 'type', value: 'module' });
attrs.push('type="module"');
} else {
attrs.push({ name: 'defer', value: '' });
attrs.push('defer');
}
} else {
attrs.push({ name: 'defer', value: '' });
attrs.push('defer');
}

if (params.sri) {
if (sri) {
const content = await loadOutputFile(script);
attrs.push(_generateSriAttributes(content));
attrs.push(generateSriAttributes(content));
}

const baseElement = treeAdapter.createElement('script', undefined, attrs);
treeAdapter.setTemplateContent(scriptsElements, baseElement);
scriptTags.push(`<script ${attrs.join(' ')}></script>`);
}

indexSource.insert(
// parse5 does not provide locations if malformed html is present
bodyElement.sourceCodeLocation?.endTag?.startOffset || params.inputContent.indexOf('</body>'),
parse5.serialize(scriptsElements, { treeAdapter }).replace(/\=""/g, ''),
);

// Adjust base href if specified
if (typeof params.baseHref == 'string') {
// tslint:disable-next-line: no-any
let baseElement = headElement.children.find((t: any) => t.name === 'base');
const baseFragment = treeAdapter.createDocumentFragment();

if (!baseElement) {
baseElement = treeAdapter.createElement('base', undefined, [
{ name: 'href', value: params.baseHref },
]);

treeAdapter.setTemplateContent(baseFragment, baseElement);
indexSource.insert(
headElement.sourceCodeLocation.startTag.endOffset,
parse5.serialize(baseFragment, { treeAdapter }),
);
} else {
baseElement.attribs['href'] = params.baseHref;
treeAdapter.setTemplateContent(baseFragment, baseElement);
indexSource.replace(
baseElement.sourceCodeLocation.startOffset,
baseElement.sourceCodeLocation.endOffset - 1,
parse5.serialize(baseFragment, { treeAdapter }),
);
}
}

const styleElements = treeAdapter.createDocumentFragment();
const linkTags: string[] = [];
for (const stylesheet of stylesheets) {
const attrs = [
{ name: 'rel', value: 'stylesheet' },
{ name: 'href', value: (params.deployUrl || '') + stylesheet },
`rel="stylesheet"`,
`href="${deployUrl}${stylesheet}"`,
];

if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
attrs.push(`crossorigin="${crossOrigin}"`);
}

if (params.sri) {
if (sri) {
const content = await loadOutputFile(stylesheet);
attrs.push(_generateSriAttributes(content));
attrs.push(generateSriAttributes(content));
}

const element = treeAdapter.createElement('link', undefined, attrs);
treeAdapter.setTemplateContent(styleElements, element);
linkTags.push(`<link ${attrs.join(' ')}>`);
}

indexSource.insert(
// parse5 does not provide locations if malformed html is present
headElement.sourceCodeLocation?.endTag?.startOffset || params.inputContent.indexOf('</head>'),
parse5.serialize(styleElements, { treeAdapter }),
);

// Adjust document locale if specified
if (typeof params.lang == 'string') {
const htmlFragment = treeAdapter.createDocumentFragment();
htmlElement.attribs['lang'] = params.lang;

// we want only openning tag
htmlElement.children = [];

treeAdapter.setTemplateContent(htmlFragment, htmlElement);
indexSource.replace(
htmlElement.sourceCodeLocation.startTag.startOffset,
htmlElement.sourceCodeLocation.startTag.endOffset - 1,
parse5.serialize(htmlFragment, { treeAdapter }).replace('</html>', ''),
);
}
const { rewriter, transformedContent } = await htmlRewritingStream(inputContent);
const baseTagExists = inputContent.includes('<base');

rewriter
.on('startTag', tag => {
switch (tag.tagName) {
case 'html':
// Adjust document locale if specified
if (isString(lang)) {
updateAttribute(tag, 'lang', lang);
}
break;
case 'head':
// Base href should be added before any link, meta tags
if (!baseTagExists && isString(baseHref)) {
rewriter.emitStartTag(tag);
rewriter.emitRaw(`<base href="${baseHref}">`);

return;
}
break;
case 'base':
// Adjust base href if specified
if (isString(baseHref)) {
updateAttribute(tag, 'href', baseHref);
}
break;
}

rewriter.emitStartTag(tag);
})
.on('endTag', tag => {
switch (tag.tagName) {
case 'head':
for (const linkTag of linkTags) {
rewriter.emitRaw(linkTag);
}
break;
case 'body':
// Add script tags
for (const scriptTag of scriptTags) {
rewriter.emitRaw(scriptTag);
}
break;
}

rewriter.emitEndTag(tag);
});

return indexSource.source();
return transformedContent;
}

function _generateSriAttributes(content: string) {
function generateSriAttributes(content: string): string {
const algo = 'sha384';
const hash = createHash(algo)
.update(content, 'utf8')
.digest('base64');

return { name: 'integrity', value: `${algo}-${hash}` };
return `integrity="${algo}-${hash}"`;
}

function updateAttribute(tag: { attrs: { name: string, value: string }[] }, name: string, value: string): void {
const index = tag.attrs.findIndex(a => a.name === name);
const newValue = { name, value };

if (index === -1) {
tag.attrs.push(newValue);
} else {
tag.attrs[index] = newValue;
}
}

function isString(value: unknown): value is string {
return typeof value === 'string';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { Readable, Writable } from 'stream';

export async function htmlRewritingStream(content: string): Promise<{
rewriter: import('parse5-html-rewriting-stream'),
transformedContent: Promise<string>,
}> {
const chunks: Buffer[] = [];
const rewriter = new (await import('parse5-html-rewriting-stream'))();

return {
rewriter,
transformedContent: new Promise(resolve => {
new Readable({
encoding: 'utf8',
read(): void {
this.push(Buffer.from(content));
this.push(null);
},
})
.pipe(rewriter)
.pipe(new Writable({
write(chunk: string | Buffer, encoding: string | undefined, callback: Function): void {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk);
callback();
},
final(callback: (error?: Error) => void): void {
callback();
resolve(Buffer.concat(chunks).toString());
},
}));
}),
};
}
Loading