From d5332b4e231cf659824830f16d2e4260ef45f4bb Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Thu, 26 Jan 2023 14:17:13 -0500 Subject: [PATCH] feat(webpack): move web-specific options into withWeb plugin (#14590) --- packages/react/plugins/with-react.ts | 9 +- packages/webpack/package.json | 1 - .../executors/dev-server/dev-server.impl.ts | 2 - .../dev-server/lib/get-dev-server-config.ts | 24 -- .../webpack/lib/get-emitted-files.ts | 38 -- .../webpack/src/executors/webpack/schema.d.ts | 35 +- .../src/executors/webpack/webpack.impl.ts | 24 +- .../webpack/src/plugins/stats-json-plugin.ts | 2 +- .../src/plugins/write-index-html-plugin.ts | 371 ++++++++++++++++++ .../plugins/index-file/augment-index-html.ts | 232 ----------- .../index-file/html-rewriting-stream.ts | 43 -- .../index-file/index-html-generator.ts | 136 ------- .../plugins/index-html-webpack-plugin.ts | 130 ------ .../src/utils/webpack/write-index-html.ts | 331 ---------------- packages/webpack/src/utils/with-web.ts | 162 +++++--- 15 files changed, 510 insertions(+), 1030 deletions(-) delete mode 100644 packages/webpack/src/executors/webpack/lib/get-emitted-files.ts create mode 100644 packages/webpack/src/plugins/write-index-html-plugin.ts delete mode 100644 packages/webpack/src/utils/webpack/plugins/index-file/augment-index-html.ts delete mode 100644 packages/webpack/src/utils/webpack/plugins/index-file/html-rewriting-stream.ts delete mode 100644 packages/webpack/src/utils/webpack/plugins/index-file/index-html-generator.ts delete mode 100644 packages/webpack/src/utils/webpack/plugins/index-html-webpack-plugin.ts delete mode 100644 packages/webpack/src/utils/webpack/write-index-html.ts diff --git a/packages/react/plugins/with-react.ts b/packages/react/plugins/with-react.ts index 61c99b7bea487..26c626504656e 100644 --- a/packages/react/plugins/with-react.ts +++ b/packages/react/plugins/with-react.ts @@ -1,13 +1,18 @@ import type { Configuration } from 'webpack'; +import type { WithWebOptions } from '@nrwl/webpack'; const processed = new Set(); -export function withReact() { +interface WithReactOptions extends WithWebOptions {} + +export function withReact(pluginOptions: WithReactOptions = {}) { return function configure(config: Configuration, _ctx?: any): Configuration { const { withWeb } = require('@nrwl/webpack'); if (processed.has(config)) return config; - config = withWeb()(config, _ctx); + + // Apply web config for CSS, JSX, index.html handling, etc. + config = withWeb(pluginOptions)(config, _ctx); config.module.rules.push({ test: /\.svg$/, diff --git a/packages/webpack/package.json b/packages/webpack/package.json index db1e7fff25ff6..65e0a7264a164 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -50,7 +50,6 @@ "loader-utils": "^2.0.3", "mini-css-extract-plugin": "~2.4.7", "parse5": "4.0.0", - "parse5-html-rewriting-stream": "6.0.1", "postcss": "^8.4.14", "postcss-import": "~14.1.0", "postcss-loader": "^6.1.1", diff --git a/packages/webpack/src/executors/dev-server/dev-server.impl.ts b/packages/webpack/src/executors/dev-server/dev-server.impl.ts index 8ec3325e32874..cad8347740bcc 100644 --- a/packages/webpack/src/executors/dev-server/dev-server.impl.ts +++ b/packages/webpack/src/executors/dev-server/dev-server.impl.ts @@ -17,7 +17,6 @@ import { import { runWebpackDevServer } from '../../utils/run-webpack'; import { resolveCustomWebpackConfig } from '../../utils/webpack/custom-webpack'; import { normalizeOptions } from '../webpack/lib/normalize-options'; -import { getEmittedFiles } from '../webpack/lib/get-emitted-files'; import { WebpackExecutorOptions } from '../webpack/schema'; import { WebDevServerOptions } from './schema'; @@ -83,7 +82,6 @@ export async function* devServerExecutor( map(({ baseUrl, stats }) => { return { baseUrl, - emittedFiles: getEmittedFiles(stats), success: !stats.hasErrors(), }; }) diff --git a/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts b/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts index 59e7f1cacbbf6..e16fe94e1033b 100644 --- a/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts +++ b/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts @@ -2,14 +2,11 @@ import { ExecutorContext, logger } from '@nrwl/devkit'; import type { Configuration as WebpackConfiguration } from 'webpack'; import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server'; import * as path from 'path'; -import { basename, resolve } from 'path'; import { getWebpackConfig } from '../../webpack/lib/get-webpack-config'; import { WebDevServerOptions } from '../schema'; import { buildServePath } from './serve-path'; import { readFileSync } from 'fs-extra'; -import { generateEntryPoints } from '../../../utils//webpack/package-chunk-sort'; -import { IndexHtmlWebpackPlugin } from '../../../utils/webpack/plugins/index-html-webpack-plugin'; import { NormalizedWebpackExecutorOptions } from '../../webpack/schema'; export function getDevServerConfig( @@ -28,27 +25,6 @@ export function getDevServerConfig( buildOptions ); - const { - deployUrl, - subresourceIntegrity, - scripts = [], - styles = [], - index, - baseHref, - } = buildOptions; - - webpackConfig.plugins.push( - new IndexHtmlWebpackPlugin({ - indexPath: resolve(workspaceRoot, index), - outputPath: basename(index), - baseHref, - entrypoints: generateEntryPoints({ scripts, styles }), - deployUrl, - sri: subresourceIntegrity, - moduleEntrypoints: [], - }) - ); - return webpackConfig as WebpackConfiguration; } diff --git a/packages/webpack/src/executors/webpack/lib/get-emitted-files.ts b/packages/webpack/src/executors/webpack/lib/get-emitted-files.ts deleted file mode 100644 index fb2554041ed33..0000000000000 --- a/packages/webpack/src/executors/webpack/lib/get-emitted-files.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Stats } from 'webpack'; -import { extname } from 'path'; - -import { EmittedFile } from '../../../utils/models'; - -export function getEmittedFiles(stats: Stats): EmittedFile[] { - const { compilation } = stats; - const files: EmittedFile[] = []; - // adds all chunks to the list of emitted files such as lazy loaded modules - for (const chunk of compilation.chunks) { - for (const file of chunk.files) { - files.push({ - // The id is guaranteed to exist at this point in the compilation process - // tslint:disable-next-line: no-non-null-assertion - id: chunk.id.toString(), - name: chunk.name, - file, - extension: extname(file), - initial: chunk.isOnlyInitial(), - }); - } - } - // other all files - for (const file of Object.keys(compilation.assets)) { - files.push({ - file, - extension: extname(file), - initial: false, - asset: true, - }); - } - // dedupe - return files.filter( - ({ file, name }, index) => - files.findIndex((f) => f.file === file && (!name || name === f.name)) === - index - ); -} diff --git a/packages/webpack/src/executors/webpack/schema.d.ts b/packages/webpack/src/executors/webpack/schema.d.ts index 49fba9ccaa7dc..393f61b4f37fc 100644 --- a/packages/webpack/src/executors/webpack/schema.d.ts +++ b/packages/webpack/src/executors/webpack/schema.d.ts @@ -1,5 +1,4 @@ import { AssetGlob } from '@nrwl/workspace/src/utilities/assets'; -import { CrossOriginValue } from '../../utils/webpack/write-index-html'; export interface AssetGlobPattern { glob: string; @@ -40,20 +39,14 @@ export interface OptimizationOptions { export interface WebpackExecutorOptions { additionalEntryPoints?: AdditionalEntryPoint[]; assets?: Array; - baseHref?: string; buildLibsFromSource?: boolean; commonChunk?: boolean; compiler?: 'babel' | 'swc' | 'tsc'; - crossOrigin?: CrossOriginValue; deleteOutputPath?: boolean; - deployUrl?: string; externalDependencies?: 'all' | 'none' | string[]; - extractCss?: boolean; extractLicenses?: boolean; fileReplacements?: FileReplacement[]; - generateIndexHtml?: boolean; generatePackageJson?: boolean; - index?: string; isolatedConfig?: boolean; main: string; memoryLimit?: number; @@ -64,15 +57,10 @@ export interface WebpackExecutorOptions { outputPath: string; poll?: number; polyfills?: string; - postcssConfig?: string; progress?: boolean; runtimeChunk?: boolean; - scripts?: Array; sourceMap?: boolean | 'hidden'; statsJson?: boolean; - stylePreprocessorOptions?: any; - styles?: Array; - subresourceIntegrity?: boolean; target?: 'node' | 'web'; transformers?: TransformerEntry[]; tsConfig: string; @@ -80,6 +68,29 @@ export interface WebpackExecutorOptions { verbose?: boolean; watch?: boolean; webpackConfig?: string; + // TODO(jack): Also deprecate these in schema.json once we have migration from executor options to webpack.config.js file. + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + baseHref?: string; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + deployUrl?: string; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + extractCss?: boolean; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + generateIndexHtml?: boolean; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + index?: string; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + postcssConfig?: string; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + scripts?: Array; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + stylePreprocessorOptions?: any; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + styles?: Array; + /** @deprecated Moved to withWeb options from `@nrwl/webpack` */ + subresourceIntegrity?: boolean; } export interface NormalizedWebpackExecutorOptions diff --git a/packages/webpack/src/executors/webpack/webpack.impl.ts b/packages/webpack/src/executors/webpack/webpack.impl.ts index 37f9351bce316..70e5481d3849f 100644 --- a/packages/webpack/src/executors/webpack/webpack.impl.ts +++ b/packages/webpack/src/executors/webpack/webpack.impl.ts @@ -4,24 +4,21 @@ import { eachValueFrom } from '@nrwl/devkit/src/utils/rxjs-for-await'; import type { Configuration } from 'webpack'; import { of } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators'; -import { basename, join, resolve } from 'path'; +import { resolve } from 'path'; import { calculateProjectDependencies, createTmpTsConfig, } from '@nrwl/workspace/src/utilities/buildable-libs-utils'; import { getWebpackConfig } from './lib/get-webpack-config'; -import { getEmittedFiles } from './lib/get-emitted-files'; import { runWebpack } from './lib/run-webpack'; import { deleteOutputDir } from '../../utils/fs'; -import { writeIndexHtml } from '../../utils/webpack/write-index-html'; import { resolveCustomWebpackConfig } from '../../utils/webpack/custom-webpack'; import type { NormalizedWebpackExecutorOptions, WebpackExecutorOptions, } from './schema'; import { normalizeOptions } from './lib/normalize-options'; -import { EmittedFile } from '../../utils/models'; async function getWebpackConfigs( options: NormalizedWebpackExecutorOptions, @@ -32,6 +29,7 @@ async function getWebpackConfigs( `Using "isolatedConfig" without a "webpackConfig" is not supported.` ); } + let customWebpack = null; if (options.webpackConfig) { @@ -73,7 +71,6 @@ export type WebpackExecutorEvent = | { success: true; outfile: string; - emittedFiles: EmittedFile[]; options?: WebpackExecutorOptions; }; @@ -151,22 +148,6 @@ export async function* webpackExecutor( }), switchMap(async (result) => { const success = result && !result.hasErrors(); - const emittedFiles = getEmittedFiles(result); - if (options.index && options.generateIndexHtml) { - await writeIndexHtml({ - crossOrigin: options.crossOrigin, - sri: options.subresourceIntegrity, - outputPath: join(options.outputPath, basename(options.index)), - indexPath: join(context.root, options.index), - files: emittedFiles.filter((x) => x.extension === '.css'), - noModuleFiles: [], - moduleFiles: emittedFiles, - baseHref: options.baseHref, - deployUrl: options.deployUrl, - scripts: options.scripts, - styles: options.styles, - }); - } return { success, outfile: resolve( @@ -174,7 +155,6 @@ export async function* webpackExecutor( options.outputPath, options.outputFileName ), - emittedFiles, options, }; }) diff --git a/packages/webpack/src/plugins/stats-json-plugin.ts b/packages/webpack/src/plugins/stats-json-plugin.ts index c6050713267d5..f1852f0a02b06 100644 --- a/packages/webpack/src/plugins/stats-json-plugin.ts +++ b/packages/webpack/src/plugins/stats-json-plugin.ts @@ -2,7 +2,7 @@ import { Compiler, sources } from 'webpack'; export class StatsJsonPlugin { apply(compiler: Compiler) { - compiler.hooks.emit.tap('angular-cli-stats', (compilation) => { + compiler.hooks.emit.tap('StatsJsonPlugin', (compilation) => { const data = JSON.stringify(compilation.getStats().toJson('verbose')); compilation.assets[`stats.json`] = new sources.RawSource(data); }); diff --git a/packages/webpack/src/plugins/write-index-html-plugin.ts b/packages/webpack/src/plugins/write-index-html-plugin.ts new file mode 100644 index 0000000000000..15f9e2be184ae --- /dev/null +++ b/packages/webpack/src/plugins/write-index-html-plugin.ts @@ -0,0 +1,371 @@ +import * as webpack from 'webpack'; +import { Compiler } from 'webpack'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; + +import { EmittedFile, ExtraEntryPoint } from '../utils/models'; +import { interpolateEnvironmentVariablesToIndex } from '../utils/webpack/interpolate-env-variables-to-index'; +import { generateEntryPoints } from '../utils/webpack/package-chunk-sort'; +import { extname } from 'path'; + +const parse5 = require('parse5'); + +export interface WriteIndexHtmlOptions { + indexPath: string; + outputPath: string; + baseHref?: string; + deployUrl?: string; + sri?: boolean; + scripts?: ExtraEntryPoint[]; + styles?: ExtraEntryPoint[]; + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; +} + +export class WriteIndexHtmlPlugin { + constructor(private readonly options: WriteIndexHtmlOptions) {} + + apply(compiler: Compiler) { + const { + outputPath, + indexPath, + baseHref, + deployUrl, + sri = false, + scripts = [], + styles = [], + crossOrigin, + } = this.options; + compiler.hooks.thisCompilation.tap( + 'WriteIndexHtmlPlugin', + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'WriteIndexHtmlPlugin', + // After minification and sourcemaps are done + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + }, + () => { + const moduleFiles = this.getEmittedFiles(compilation); + const files = moduleFiles.filter((x) => x.extension === '.css'); + let content = readFileSync(indexPath).toString(); + content = this.stripBom(content); + compilation.assets[outputPath] = this.augmentIndexHtml({ + input: outputPath, + inputContent: interpolateEnvironmentVariablesToIndex( + content, + deployUrl + ), + baseHref, + deployUrl, + crossOrigin, + sri, + entrypoints: generateEntryPoints({ scripts, styles }), + files: this.filterAndMapBuildFiles(files, ['.js', '.css']), + moduleFiles: this.filterAndMapBuildFiles(moduleFiles, ['.js']), + loadOutputFile: (filePath) => + compilation.assets[filePath].source().toString(), + }); + } + ); + } + ); + } + + private getEmittedFiles(compilation: webpack.Compilation): EmittedFile[] { + const files: EmittedFile[] = []; + // adds all chunks to the list of emitted files such as lazy loaded modules + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + files.push({ + // The id is guaranteed to exist at this point in the compilation process + // tslint:disable-next-line: no-non-null-assertion + id: chunk.id.toString(), + name: chunk.name, + file, + extension: extname(file), + initial: chunk.isOnlyInitial(), + }); + } + } + // other all files + for (const file of Object.keys(compilation.assets)) { + files.push({ + file, + extension: extname(file), + initial: false, + asset: true, + }); + } + // dedupe + return files.filter( + ({ file, name }, index) => + files.findIndex( + (f) => f.file === file && (!name || name === f.name) + ) === index + ); + } + + private stripBom(data: string) { + return data.replace(/^\uFEFF/, ''); + } + + private augmentIndexHtml(params: { + /* Input file name (e. g. index.html) */ + input: string; + /* Input contents */ + inputContent: string; + baseHref?: string; + deployUrl?: string; + sri: boolean; + /** crossorigin attribute setting of elements that provide CORS support */ + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; + /* + * Files emitted by the build. + */ + files: { + file: string; + name: string; + extension: string; + }[]; + /** Files that should be added using 'module'. */ + moduleFiles?: { + file: string; + name: string; + extension: string; + }[]; + /* + * Function that loads a file used. + * This allows us to use different routines within the IndexHtmlWebpackPlugin and + * when used without this plugin. + */ + loadOutputFile: (file: string) => string; + /** Used to sort the inseration of files in the HTML file */ + entrypoints: string[]; + }): webpack.sources.Source { + const { loadOutputFile, files, moduleFiles = [], entrypoints } = params; + + let { crossOrigin = 'none' } = params; + if (params.sri && crossOrigin === 'none') { + crossOrigin = 'anonymous'; + } + + const stylesheets = new Set(); + const scripts = new Set(); + + // Sort files in the order we want to insert them by entrypoint and dedupes duplicates + const mergedFiles = [...moduleFiles, ...files]; + for (const entrypoint of entrypoints) { + for (const { extension, file, name } of mergedFiles) { + if (name !== entrypoint) { + continue; + } + + switch (extension) { + case '.js': + scripts.add(file); + break; + case '.css': + stylesheets.add(file); + break; + } + } + } + + // Find the head and body elements + const treeAdapter = parse5.treeAdapters.default; + const document = parse5.parse(params.inputContent, { + treeAdapter, + locationInfo: true, + }); + let headElement; + let bodyElement; + for (const docChild of document.childNodes) { + if (docChild.tagName === 'html') { + for (const htmlChild of docChild.childNodes) { + if (htmlChild.tagName === 'head') { + headElement = htmlChild; + } else if (htmlChild.tagName === 'body') { + bodyElement = htmlChild; + } + } + } + } + + if (!headElement || !bodyElement) { + throw new Error('Missing head and/or body elements'); + } + + // Determine script insertion point + let scriptInsertionPoint; + if (bodyElement.__location && bodyElement.__location.endTag) { + scriptInsertionPoint = bodyElement.__location.endTag.startOffset; + } else { + // Less accurate fallback + // parse5 4.x does not provide locations if malformed html is present + scriptInsertionPoint = params.inputContent.indexOf(''); + } + + let styleInsertionPoint; + if (headElement.__location && headElement.__location.endTag) { + styleInsertionPoint = headElement.__location.endTag.startOffset; + } else { + // Less accurate fallback + // parse5 4.x does not provide locations if malformed html is present + styleInsertionPoint = params.inputContent.indexOf(''); + } + + // Inject into the html + const indexSource = new webpack.sources.ReplaceSource( + new webpack.sources.RawSource(params.inputContent, false), + params.input + ); + + let scriptElements = ''; + for (const script of scripts) { + const attrs: { name: string; value: string | null }[] = [ + { name: 'src', value: (params.deployUrl || '') + script }, + ]; + + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + + // We want to include nomodule or module when a file is not common amongs all + // such as runtime.js + const scriptPredictor = ({ + file, + }: { + file: string; + name: string; + extension: string; + }): boolean => file === script; + if (!files.some(scriptPredictor)) { + // in some cases for differential loading file with the same name is avialable in both + // nomodule and module such as scripts.js + // we shall not add these attributes if that's the case + const isModuleType = moduleFiles.some(scriptPredictor); + + if (isModuleType) { + attrs.push({ name: 'type', value: 'module' }); + } else { + attrs.push({ name: 'defer', value: null }); + } + } else { + attrs.push({ name: 'type', value: 'module' }); + } + + if (params.sri) { + const content = loadOutputFile(script); + attrs.push(...this.generateSriAttributes(content)); + } + + const attributes = attrs + .map((attr) => + attr.value === null ? attr.name : `${attr.name}="${attr.value}"` + ) + .join(' '); + scriptElements += ``; + } + + indexSource.insert(scriptInsertionPoint, scriptElements); + + // Adjust base href if specified + if (typeof params.baseHref == 'string') { + let baseElement; + for (const headChild of headElement.childNodes) { + if (headChild.tagName === 'base') { + baseElement = headChild; + } + } + + const baseFragment = treeAdapter.createDocumentFragment(); + + if (!baseElement) { + baseElement = treeAdapter.createElement('base', undefined, [ + { name: 'href', value: params.baseHref }, + ]); + + treeAdapter.appendChild(baseFragment, baseElement); + indexSource.insert( + headElement.__location.startTag.endOffset, + parse5.serialize(baseFragment, { treeAdapter }) + ); + } else { + let hrefAttribute; + for (const attribute of baseElement.attrs) { + if (attribute.name === 'href') { + hrefAttribute = attribute; + } + } + if (hrefAttribute) { + hrefAttribute.value = params.baseHref; + } else { + baseElement.attrs.push({ name: 'href', value: params.baseHref }); + } + + treeAdapter.appendChild(baseFragment, baseElement); + indexSource.replace( + baseElement.__location.startOffset, + baseElement.__location.endOffset, + parse5.serialize(baseFragment, { treeAdapter }) + ); + } + } + + const styleElements = treeAdapter.createDocumentFragment(); + for (const stylesheet of stylesheets) { + const attrs = [ + { name: 'rel', value: 'stylesheet' }, + { name: 'href', value: (params.deployUrl || '') + stylesheet }, + ]; + + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + + if (params.sri) { + const content = loadOutputFile(stylesheet); + attrs.push(...this.generateSriAttributes(content)); + } + + const element = treeAdapter.createElement('link', undefined, attrs); + treeAdapter.appendChild(styleElements, element); + } + + indexSource.insert( + styleInsertionPoint, + parse5.serialize(styleElements, { treeAdapter }) + ); + + return indexSource; + } + + private generateSriAttributes(content: string) { + const algo = 'sha384'; + const hash = createHash(algo).update(content, 'utf8').digest('base64'); + + return [{ name: 'integrity', value: `${algo}-${hash}` }]; + } + + private filterAndMapBuildFiles( + files: EmittedFile[], + extensionFilter: string[] + ): { + file: string; + name: string; + extension: string; + }[] { + const filteredFiles: { + file: string; + name: string; + extension: string; + }[] = []; + for (const { file, name, extension, initial } of files) { + if (name && initial && extensionFilter.includes(extension)) { + filteredFiles.push({ file, extension, name }); + } + } + + return filteredFiles; + } +} diff --git a/packages/webpack/src/utils/webpack/plugins/index-file/augment-index-html.ts b/packages/webpack/src/utils/webpack/plugins/index-file/augment-index-html.ts deleted file mode 100644 index add879d69c10f..0000000000000 --- a/packages/webpack/src/utils/webpack/plugins/index-file/augment-index-html.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createHash } from 'crypto'; -import { htmlRewritingStream } from './html-rewriting-stream'; - -export type LoadOutputFileFunctionType = (file: string) => Promise; - -export type CrossOriginValue = 'none' | 'anonymous' | 'use-credentials'; - -export interface AugmentIndexHtmlOptions { - /* Input contents */ - html: string; - baseHref?: string; - deployUrl?: string; - sri: boolean; - /** crossorigin attribute setting of elements that provide CORS support */ - crossOrigin?: CrossOriginValue; - /* - * Files emitted by the build. - * Js files will be added without 'nomodule' nor 'module'. - */ - files: FileInfo[]; - /** Files that should be added using 'nomodule'. */ - noModuleFiles?: FileInfo[]; - /** Files that should be added using 'module'. */ - moduleFiles?: FileInfo[]; - /* - * Function that loads a file used. - * This allows us to use different routines within the IndexHtmlWebpackPlugin and - * when used without this plugin. - */ - loadOutputFile: LoadOutputFileFunctionType; - /** Used to sort the inseration of files in the HTML file */ - entrypoints: string[]; - /** Used to set the document default locale */ - lang?: string; -} - -export interface FileInfo { - file: string; - name: string; - extension: string; -} - -/* - * Helper function used by the IndexHtmlWebpackPlugin. - * Can also be directly used by builder, e. g. in order to generate an index.html - * after processing several configurations in order to build different sets of - * bundles for differential serving. - */ -export async function augmentIndexHtml( - params: AugmentIndexHtmlOptions -): Promise { - const { - loadOutputFile, - files, - noModuleFiles = [], - moduleFiles = [], - entrypoints, - sri, - deployUrl = '', - lang, - baseHref, - html, - } = params; - - let { crossOrigin = 'none' } = params; - if (sri && crossOrigin === 'none') { - crossOrigin = 'anonymous'; - } - - const stylesheets = new Set(); - const scripts = new Set(); - - // Sort files in the order we want to insert them by entrypoint and dedupes duplicates - const mergedFiles = [...moduleFiles, ...noModuleFiles, ...files]; - for (const entrypoint of entrypoints) { - for (const { extension, file, name } of mergedFiles) { - if (name !== entrypoint) { - continue; - } - - switch (extension) { - case '.js': - scripts.add(file); - break; - case '.css': - stylesheets.add(file); - break; - } - } - } - - let scriptTags: string[] = []; - for (const script of scripts) { - const attrs = [`src="${deployUrl}${script}"`]; - - if (crossOrigin !== 'none') { - attrs.push(`crossorigin="${crossOrigin}"`); - } - - // We want to include nomodule or module when a file is not common amongs all - // such as runtime.js - const scriptPredictor = ({ file }: FileInfo): boolean => file === script; - if (!files.some(scriptPredictor)) { - // in some cases for differential loading file with the same name is available in both - // nomodule and module such as scripts.js - // we shall not add these attributes if that's the case - const isNoModuleType = noModuleFiles.some(scriptPredictor); - const isModuleType = moduleFiles.some(scriptPredictor); - - if (isNoModuleType && !isModuleType) { - attrs.push('nomodule', 'defer'); - } else if (isModuleType) { - attrs.push('type="module"'); - } else { - attrs.push('defer'); - } - } else { - attrs.push('type="module"'); - } - - if (sri) { - const content = await loadOutputFile(script); - attrs.push(generateSriAttributes(content)); - } - - scriptTags.push(``); - } - - let linkTags: string[] = []; - for (const stylesheet of stylesheets) { - const attrs = [`rel="stylesheet"`, `href="${deployUrl}${stylesheet}"`]; - - if (crossOrigin !== 'none') { - attrs.push(`crossorigin="${crossOrigin}"`); - } - - if (sri) { - const content = await loadOutputFile(stylesheet); - attrs.push(generateSriAttributes(content)); - } - - linkTags.push(``); - } - - const { rewriter, transformedContent } = await htmlRewritingStream(html); - const baseTagExists = html.includes(' { - 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(``); - - 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); - } - - linkTags = []; - break; - case 'body': - // Add script tags - for (const scriptTag of scriptTags) { - rewriter.emitRaw(scriptTag); - } - - scriptTags = []; - break; - } - - rewriter.emitEndTag(tag); - }); - - const content = await transformedContent; - - if (linkTags.length || scriptTags.length) { - // In case no body/head tags are not present (dotnet partial templates) - return linkTags.join('') + scriptTags.join('') + content; - } - - return content; -} - -function generateSriAttributes(content: string): string { - const algo = 'sha384'; - const hash = createHash(algo).update(content, 'utf8').digest('base64'); - - 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'; -} diff --git a/packages/webpack/src/utils/webpack/plugins/index-file/html-rewriting-stream.ts b/packages/webpack/src/utils/webpack/plugins/index-file/html-rewriting-stream.ts deleted file mode 100644 index f55ed21da81af..0000000000000 --- a/packages/webpack/src/utils/webpack/plugins/index-file/html-rewriting-stream.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Readable, Writable } from 'stream'; - -export async function htmlRewritingStream(content: string): Promise<{ - rewriter: import('parse5-html-rewriting-stream'); - transformedContent: Promise; -}> { - 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 as BufferEncoding) - : chunk - ); - callback(); - }, - final(callback: (error?: Error) => void): void { - callback(); - resolve(Buffer.concat(chunks).toString()); - }, - }) - ); - }), - }; -} diff --git a/packages/webpack/src/utils/webpack/plugins/index-file/index-html-generator.ts b/packages/webpack/src/utils/webpack/plugins/index-file/index-html-generator.ts deleted file mode 100644 index ffad62d96dc54..0000000000000 --- a/packages/webpack/src/utils/webpack/plugins/index-file/index-html-generator.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as fs from 'fs'; -import { join } from 'path'; -import { interpolateEnvironmentVariablesToIndex } from '../../interpolate-env-variables-to-index'; -import { - augmentIndexHtml, - CrossOriginValue, - FileInfo, -} from './augment-index-html'; - -function stripBom(data: string) { - return data.replace(/^\uFEFF/, ''); -} - -type IndexHtmlGeneratorPlugin = ( - html: string, - options: IndexHtmlGeneratorProcessOptions -) => Promise; - -export interface IndexHtmlGeneratorProcessOptions { - lang?: string | undefined; - baseHref?: string | undefined; - outputPath: string; - files: FileInfo[]; - moduleFiles: FileInfo[]; -} - -export interface IndexHtmlGeneratorOptions { - indexPath: string; - deployUrl?: string; - sri?: boolean; - entrypoints: string[]; - postTransform?: IndexHtmlTransform; - crossOrigin?: CrossOriginValue; - optimization?: any; - WOFFSupportNeeded?: boolean; -} - -export type IndexHtmlTransform = (content: string) => Promise; - -export interface IndexHtmlTransformResult { - content: string; - warnings: string[]; - errors: string[]; -} - -export class IndexHtmlGenerator { - private readonly plugins: IndexHtmlGeneratorPlugin[]; - - constructor(readonly options: IndexHtmlGeneratorOptions) { - const extraPlugins: IndexHtmlGeneratorPlugin[] = []; - this.plugins = [ - augmentIndexHtmlPlugin(this), - ...extraPlugins, - postTransformPlugin(this), - ]; - } - - async process( - options: IndexHtmlGeneratorProcessOptions - ): Promise { - let content = stripBom(await this.readIndex(this.options.indexPath)); - content = interpolateEnvironmentVariablesToIndex( - content, - this.options.deployUrl - ); - const warnings: string[] = []; - const errors: string[] = []; - - for (const plugin of this.plugins) { - const result = await plugin(content, options); - if (typeof result === 'string') { - content = result; - } else { - content = result.content; - - if (result.warnings.length) { - warnings.push(...result.warnings); - } - - if (result.errors.length) { - errors.push(...result.errors); - } - } - } - - return { - content, - warnings, - errors, - }; - } - - async readAsset(path: string): Promise { - return fs.promises.readFile(path, 'utf-8'); - } - - protected async readIndex(path: string): Promise { - return fs.promises.readFile(path, 'utf-8'); - } -} - -function augmentIndexHtmlPlugin( - generator: IndexHtmlGenerator -): IndexHtmlGeneratorPlugin { - const { - deployUrl, - crossOrigin, - sri = false, - entrypoints, - } = generator.options; - - return async (html, options) => { - const { lang, baseHref, outputPath = '', files, moduleFiles } = options; - - return augmentIndexHtml({ - html, - baseHref, - deployUrl, - crossOrigin, - sri, - lang, - entrypoints, - loadOutputFile: (filePath) => - generator.readAsset(join(outputPath, filePath)), - moduleFiles, - files, - }); - }; -} - -function postTransformPlugin({ - options, -}: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { - return async (html) => - options.postTransform ? options.postTransform(html) : html; -} diff --git a/packages/webpack/src/utils/webpack/plugins/index-html-webpack-plugin.ts b/packages/webpack/src/utils/webpack/plugins/index-html-webpack-plugin.ts deleted file mode 100644 index c77b4472d986d..0000000000000 --- a/packages/webpack/src/utils/webpack/plugins/index-html-webpack-plugin.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as webpack from 'webpack'; -import { basename, dirname, extname } from 'path'; -import { FileInfo } from './index-file/augment-index-html'; -import { - IndexHtmlGenerator, - IndexHtmlGeneratorOptions, - IndexHtmlGeneratorProcessOptions, -} from './index-file/index-html-generator'; - -export interface IndexHtmlWebpackPluginOptions - extends IndexHtmlGeneratorOptions, - Omit< - IndexHtmlGeneratorProcessOptions, - 'files' | 'noModuleFiles' | 'moduleFiles' - > { - moduleEntrypoints: string[]; -} - -type Compiler = any; - -const PLUGIN_NAME = 'index-html-webpack-plugin'; - -export class IndexHtmlWebpackPlugin extends IndexHtmlGenerator { - private _compilation: any | undefined; - - get compilation(): any { - if (this._compilation) { - return this._compilation; - } - - throw new Error('compilation is undefined.'); - } - - constructor(readonly options: IndexHtmlWebpackPluginOptions) { - super(options); - } - - apply(compiler: Compiler) { - compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - this._compilation = compilation; - compilation.hooks.processAssets.tapPromise( - { - name: PLUGIN_NAME, - stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE + 1, - }, - callback - ); - }); - - function addWarning(compilation: any, message: string): void { - compilation.warnings.push(new this.webpack.WebpackError(message)); - } - - function addError(compilation: any, message: string): void { - compilation.errors.push(new this.webpack.WebpackError(message)); - } - - const callback = async (assets: Record) => { - // Get all files for selected entrypoints - const files: FileInfo[] = []; - const moduleFiles: FileInfo[] = []; - - try { - for (const [entryName, entrypoint] of this.compilation.entrypoints) { - const entryFiles: FileInfo[] = entrypoint - ?.getFiles() - ?.filter((f) => !f.endsWith('.hot-update.js')) - ?.map( - (f: string): FileInfo => ({ - name: entryName, - file: f, - extension: extname(f), - }) - ); - - if (!entryFiles) { - continue; - } - - if (this.options.moduleEntrypoints.includes(entryName)) { - moduleFiles.push(...entryFiles); - } else { - files.push(...entryFiles); - } - } - - const { content, warnings, errors } = await this.process({ - files, - moduleFiles, - outputPath: dirname(this.options.outputPath), - baseHref: this.options.baseHref, - lang: this.options.lang, - }); - - assets[this.options.outputPath] = new webpack.sources.RawSource( - content - ); - - warnings.forEach((msg) => addWarning(this.compilation, msg)); - errors.forEach((msg) => addError(this.compilation, msg)); - } catch (error) { - addError(this.compilation, error.message); - } - }; - } - - async readAsset(path: string): Promise { - const data = this.compilation.assets[basename(path)].source(); - - return typeof data === 'string' ? data : data.toString(); - } - - protected async readIndex(path: string): Promise { - return new Promise((resolve, reject) => { - this.compilation.inputFileSystem.readFile( - path, - (err?: Error, data?: string | Buffer) => { - if (err) { - reject(err); - - return; - } - - this.compilation.fileDependencies.add(path); - resolve(data?.toString() ?? ''); - } - ); - }); - } -} diff --git a/packages/webpack/src/utils/webpack/write-index-html.ts b/packages/webpack/src/utils/webpack/write-index-html.ts deleted file mode 100644 index 5f490381aabde..0000000000000 --- a/packages/webpack/src/utils/webpack/write-index-html.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { dirname, join } from 'path'; -import { readFileSync, writeFileSync } from 'fs'; -import { interpolateEnvironmentVariablesToIndex } from './interpolate-env-variables-to-index'; -import { generateEntryPoints } from './package-chunk-sort'; -import { createHash } from 'crypto'; -import * as webpack from 'webpack'; - -import type { EmittedFile, ExtraEntryPoint } from '../models'; - -function stripBom(data: string) { - return data.replace(/^\uFEFF/, ''); -} - -const parse5 = require('parse5'); - -export type LoadOutputFileFunctionType = (file: string) => string; - -export type CrossOriginValue = 'none' | 'anonymous' | 'use-credentials'; - -export interface AugmentIndexHtmlOptions { - /* Input file name (e. g. index.html) */ - input: string; - /* Input contents */ - inputContent: string; - baseHref?: string; - deployUrl?: string; - sri: boolean; - /** crossorigin attribute setting of elements that provide CORS support */ - crossOrigin?: CrossOriginValue; - /* - * Files emitted by the build. - * Js files will be added without 'nomodule' nor 'module'. - */ - files: FileInfo[]; - /** Files that should be added using 'nomodule'. */ - noModuleFiles?: FileInfo[]; - /** Files that should be added using 'module'. */ - moduleFiles?: FileInfo[]; - /* - * Function that loads a file used. - * This allows us to use different routines within the IndexHtmlWebpackPlugin and - * when used without this plugin. - */ - loadOutputFile: LoadOutputFileFunctionType; - /** Used to sort the inseration of files in the HTML file */ - entrypoints: string[]; -} - -export interface FileInfo { - file: string; - name: string; - extension: string; -} - -/* - * Helper function used by the IndexHtmlWebpackPlugin. - * Can also be directly used by builder, e. g. in order to generate an index.html - * after processing several configurations in order to build different sets of - * bundles for differential serving. - */ -export function augmentIndexHtml(params: AugmentIndexHtmlOptions): string { - const { loadOutputFile, files, moduleFiles = [], entrypoints } = params; - - let { crossOrigin = 'none' } = params; - if (params.sri && crossOrigin === 'none') { - crossOrigin = 'anonymous'; - } - - const stylesheets = new Set(); - const scripts = new Set(); - - // Sort files in the order we want to insert them by entrypoint and dedupes duplicates - const mergedFiles = [...moduleFiles, ...files]; - for (const entrypoint of entrypoints) { - for (const { extension, file, name } of mergedFiles) { - if (name !== entrypoint) { - continue; - } - - switch (extension) { - case '.js': - scripts.add(file); - break; - case '.css': - stylesheets.add(file); - break; - } - } - } - - // Find the head and body elements - const treeAdapter = parse5.treeAdapters.default; - const document = parse5.parse(params.inputContent, { - treeAdapter, - locationInfo: true, - }); - let headElement; - let bodyElement; - for (const docChild of document.childNodes) { - if (docChild.tagName === 'html') { - for (const htmlChild of docChild.childNodes) { - if (htmlChild.tagName === 'head') { - headElement = htmlChild; - } else if (htmlChild.tagName === 'body') { - bodyElement = htmlChild; - } - } - } - } - - if (!headElement || !bodyElement) { - throw new Error('Missing head and/or body elements'); - } - - // Determine script insertion point - let scriptInsertionPoint; - if (bodyElement.__location && bodyElement.__location.endTag) { - scriptInsertionPoint = bodyElement.__location.endTag.startOffset; - } else { - // Less accurate fallback - // parse5 4.x does not provide locations if malformed html is present - scriptInsertionPoint = params.inputContent.indexOf(''); - } - - let styleInsertionPoint; - if (headElement.__location && headElement.__location.endTag) { - styleInsertionPoint = headElement.__location.endTag.startOffset; - } else { - // Less accurate fallback - // parse5 4.x does not provide locations if malformed html is present - styleInsertionPoint = params.inputContent.indexOf(''); - } - - // Inject into the html - const indexSource = new webpack.sources.ReplaceSource( - new webpack.sources.RawSource(params.inputContent), - params.input - ); - - let scriptElements = ''; - for (const script of scripts) { - const attrs: { name: string; value: string | null }[] = [ - { name: 'src', value: (params.deployUrl || '') + script }, - ]; - - if (crossOrigin !== 'none') { - attrs.push({ name: 'crossorigin', value: crossOrigin }); - } - - // We want to include nomodule or module when a file is not common amongs all - // such as runtime.js - const scriptPredictor = ({ file }: FileInfo): boolean => file === script; - if (!files.some(scriptPredictor)) { - // in some cases for differential loading file with the same name is avialable in both - // nomodule and module such as scripts.js - // we shall not add these attributes if that's the case - const isModuleType = moduleFiles.some(scriptPredictor); - - if (isModuleType) { - attrs.push({ name: 'type', value: 'module' }); - } else { - attrs.push({ name: 'defer', value: null }); - } - } else { - attrs.push({ name: 'type', value: 'module' }); - } - - if (params.sri) { - const content = loadOutputFile(script); - attrs.push(..._generateSriAttributes(content)); - } - - const attributes = attrs - .map((attr) => - attr.value === null ? attr.name : `${attr.name}="${attr.value}"` - ) - .join(' '); - scriptElements += ``; - } - - indexSource.insert(scriptInsertionPoint, scriptElements); - - // Adjust base href if specified - if (typeof params.baseHref == 'string') { - let baseElement; - for (const headChild of headElement.childNodes) { - if (headChild.tagName === 'base') { - baseElement = headChild; - } - } - - const baseFragment = treeAdapter.createDocumentFragment(); - - if (!baseElement) { - baseElement = treeAdapter.createElement('base', undefined, [ - { name: 'href', value: params.baseHref }, - ]); - - treeAdapter.appendChild(baseFragment, baseElement); - indexSource.insert( - headElement.__location.startTag.endOffset, - parse5.serialize(baseFragment, { treeAdapter }) - ); - } else { - let hrefAttribute; - for (const attribute of baseElement.attrs) { - if (attribute.name === 'href') { - hrefAttribute = attribute; - } - } - if (hrefAttribute) { - hrefAttribute.value = params.baseHref; - } else { - baseElement.attrs.push({ name: 'href', value: params.baseHref }); - } - - treeAdapter.appendChild(baseFragment, baseElement); - indexSource.replace( - baseElement.__location.startOffset, - baseElement.__location.endOffset, - parse5.serialize(baseFragment, { treeAdapter }) - ); - } - } - - const styleElements = treeAdapter.createDocumentFragment(); - for (const stylesheet of stylesheets) { - const attrs = [ - { name: 'rel', value: 'stylesheet' }, - { name: 'href', value: (params.deployUrl || '') + stylesheet }, - ]; - - if (crossOrigin !== 'none') { - attrs.push({ name: 'crossorigin', value: crossOrigin }); - } - - if (params.sri) { - const content = loadOutputFile(stylesheet); - attrs.push(..._generateSriAttributes(content)); - } - - const element = treeAdapter.createElement('link', undefined, attrs); - treeAdapter.appendChild(styleElements, element); - } - - indexSource.insert( - styleInsertionPoint, - parse5.serialize(styleElements, { treeAdapter }) - ); - - return indexSource.source().toString(); -} - -function _generateSriAttributes(content: string) { - const algo = 'sha384'; - const hash = createHash(algo).update(content, 'utf8').digest('base64'); - - return [{ name: 'integrity', value: `${algo}-${hash}` }]; -} - -type ExtensionFilter = '.js' | '.css'; - -export interface WriteIndexHtmlOptions { - outputPath: string; - indexPath: string; - files?: EmittedFile[]; - noModuleFiles?: EmittedFile[]; - moduleFiles?: EmittedFile[]; - baseHref?: string; - deployUrl?: string; - sri?: boolean; - scripts?: ExtraEntryPoint[]; - styles?: ExtraEntryPoint[]; - postTransform?: IndexHtmlTransform; - crossOrigin?: CrossOriginValue; -} - -export type IndexHtmlTransform = (content: string) => Promise; - -export async function writeIndexHtml({ - outputPath, - indexPath, - files = [], - moduleFiles = [], - baseHref, - deployUrl, - sri = false, - scripts = [], - styles = [], - postTransform, - crossOrigin, -}: WriteIndexHtmlOptions) { - let content = readFileSync(indexPath).toString(); - content = stripBom(content); - content = augmentIndexHtml({ - input: outputPath, - inputContent: interpolateEnvironmentVariablesToIndex(content, deployUrl), - baseHref, - deployUrl, - crossOrigin, - sri, - entrypoints: generateEntryPoints({ scripts, styles }), - files: filterAndMapBuildFiles(files, ['.js', '.css']), - moduleFiles: filterAndMapBuildFiles(moduleFiles, '.js'), - loadOutputFile: (filePath) => - readFileSync(join(dirname(outputPath), filePath)).toString(), - }); - if (postTransform) { - content = await postTransform(content); - } - - writeFileSync(outputPath, content); -} - -function filterAndMapBuildFiles( - files: EmittedFile[], - extensionFilter: ExtensionFilter | ExtensionFilter[] -): FileInfo[] { - const filteredFiles: FileInfo[] = []; - const validExtensions: string[] = Array.isArray(extensionFilter) - ? extensionFilter - : [extensionFilter]; - - for (const { file, name, extension, initial } of files) { - if (name && initial && validExtensions.includes(extension)) { - filteredFiles.push({ file, extension, name }); - } - } - - return filteredFiles; -} diff --git a/packages/webpack/src/utils/with-web.ts b/packages/webpack/src/utils/with-web.ts index 09764e0355402..a7845f0da2c19 100644 --- a/packages/webpack/src/utils/with-web.ts +++ b/packages/webpack/src/utils/with-web.ts @@ -7,15 +7,20 @@ import { } from 'webpack'; import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity'; import * as path from 'path'; -import { basename } from 'path'; +import { basename, join } from 'path'; import { getOutputHashFormat } from '@nrwl/webpack/src/utils/hash-format'; import { PostcssCliResources } from '@nrwl/webpack/src/utils/webpack/plugins/postcss-cli-resources'; import { normalizeExtraEntryPoints } from '@nrwl/webpack/src/utils/webpack/normalize-entry'; -import { NormalizedWebpackExecutorOptions } from '../executors/webpack/schema'; +import { + ExtraEntryPointClass, + NormalizedWebpackExecutorOptions, +} from '../executors/webpack/schema'; import { getClientEnvironment } from './get-client-environment'; import { ScriptsWebpackPlugin } from './webpack/plugins/scripts-webpack-plugin'; import { getCSSModuleLocalIdent } from './get-css-module-local-ident'; +import { WriteIndexHtmlPlugin } from '../plugins/write-index-html-plugin'; +import { ExecutorContext } from '@nrwl/devkit'; import CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); import MiniCssExtractPlugin = require('mini-css-extract-plugin'); import autoprefixer = require('autoprefixer'); @@ -29,25 +34,67 @@ interface PostcssOptions { const processed = new Set(); -export function withWeb() { +export interface WithWebOptions { + baseHref?: string; + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; + deployUrl?: string; + extractCss?: boolean; + generateIndexHtml?: boolean; + index?: string; + postcssConfig?: string; + scripts?: Array; + stylePreprocessorOptions?: any; + styles?: Array; + subresourceIntegrity?: boolean; +} + +// Omit deprecated options +export type MergedOptions = Omit< + NormalizedWebpackExecutorOptions, + keyof WithWebOptions +> & + WithWebOptions; + +export function withWeb(pluginOptions: WithWebOptions = {}) { return function configure( config: Configuration, - { options }: { options: NormalizedWebpackExecutorOptions } + { + options: executorOptions, + context, + }: { options: NormalizedWebpackExecutorOptions; context: ExecutorContext } ): Configuration { if (processed.has(config)) return config; + const mergedOptions: MergedOptions = { + ...executorOptions, + ...pluginOptions, + }; const plugins = []; const stylesOptimization = - typeof options.optimization === 'object' - ? options.optimization.styles - : options.optimization; + typeof mergedOptions.optimization === 'object' + ? mergedOptions.optimization.styles + : mergedOptions.optimization; - if (Array.isArray(options.scripts)) { - plugins.push(...createScriptsPlugin(options)); + if (Array.isArray(mergedOptions.scripts)) { + plugins.push(...createScriptsPlugin(mergedOptions)); + } + if (mergedOptions.index && mergedOptions.generateIndexHtml) { + plugins.push( + new WriteIndexHtmlPlugin({ + crossOrigin: mergedOptions.crossOrigin, + sri: mergedOptions.subresourceIntegrity, + outputPath: basename(mergedOptions.index), + indexPath: join(context.root, mergedOptions.index), + baseHref: mergedOptions.baseHref, + deployUrl: mergedOptions.deployUrl, + scripts: mergedOptions.scripts, + styles: mergedOptions.styles, + }) + ); } - if (options.subresourceIntegrity) { + if (mergedOptions.subresourceIntegrity) { plugins.push(new SubresourceIntegrityPlugin()); } @@ -71,13 +118,15 @@ export function withWeb() { const globalStylePaths: string[] = []; // Determine hashing format. - const hashFormat = getOutputHashFormat(options.outputHashing as string); + const hashFormat = getOutputHashFormat( + mergedOptions.outputHashing as string + ); const includePaths: string[] = []; - if (options?.stylePreprocessorOptions?.includePaths?.length > 0) { - options.stylePreprocessorOptions.includePaths.forEach( + if (mergedOptions?.stylePreprocessorOptions?.includePaths?.length > 0) { + mergedOptions.stylePreprocessorOptions.includePaths.forEach( (includePath: string) => - includePaths.push(path.resolve(options.root, includePath)) + includePaths.push(path.resolve(mergedOptions.root, includePath)) ); } @@ -90,32 +139,34 @@ export function withWeb() { } // Process global styles. - if (options.styles.length > 0) { - normalizeExtraEntryPoints(options.styles, 'styles').forEach((style) => { - const resolvedPath = path.resolve(options.root, style.input); - // Add style entry points. - if (entry[style.bundleName]) { - entry[style.bundleName].push(resolvedPath); - } else { - entry[style.bundleName] = [resolvedPath]; + if (mergedOptions.styles.length > 0) { + normalizeExtraEntryPoints(mergedOptions.styles, 'styles').forEach( + (style) => { + const resolvedPath = path.resolve(mergedOptions.root, style.input); + // Add style entry points. + if (entry[style.bundleName]) { + entry[style.bundleName].push(resolvedPath); + } else { + entry[style.bundleName] = [resolvedPath]; + } + + // Add global css paths. + globalStylePaths.push(resolvedPath); } - - // Add global css paths. - globalStylePaths.push(resolvedPath); - }); + ); } const cssModuleRules: RuleSetRule[] = [ { test: /\.module\.css$/, exclude: globalStylePaths, - use: getCommonLoadersForCssModules(options, includePaths), + use: getCommonLoadersForCssModules(mergedOptions, includePaths), }, { test: /\.module\.(scss|sass)$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForCssModules(options, includePaths), + ...getCommonLoadersForCssModules(mergedOptions, includePaths), { loader: require.resolve('sass-loader'), options: { @@ -133,7 +184,7 @@ export function withWeb() { test: /\.module\.less$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForCssModules(options, includePaths), + ...getCommonLoadersForCssModules(mergedOptions, includePaths), { loader: require.resolve('less-loader'), options: { @@ -148,7 +199,7 @@ export function withWeb() { test: /\.module\.styl$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForCssModules(options, includePaths), + ...getCommonLoadersForCssModules(mergedOptions, includePaths), { loader: require.resolve('stylus-loader'), options: { @@ -165,18 +216,18 @@ export function withWeb() { { test: /\.css$/, exclude: globalStylePaths, - use: getCommonLoadersForGlobalCss(options, includePaths), + use: getCommonLoadersForGlobalCss(mergedOptions, includePaths), }, { test: /\.scss$|\.sass$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForGlobalCss(options, includePaths), + ...getCommonLoadersForGlobalCss(mergedOptions, includePaths), { loader: require.resolve('sass-loader'), options: { implementation: require('sass'), - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, sassOptions: { fiber: false, // bootstrap-sass requires a minimum precision of 8 @@ -191,11 +242,11 @@ export function withWeb() { test: /\.less$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForGlobalCss(options, includePaths), + ...getCommonLoadersForGlobalCss(mergedOptions, includePaths), { loader: require.resolve('less-loader'), options: { - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, lessOptions: { javascriptEnabled: true, ...lessPathOptions, @@ -208,11 +259,11 @@ export function withWeb() { test: /\.styl$/, exclude: globalStylePaths, use: [ - ...getCommonLoadersForGlobalCss(options, includePaths), + ...getCommonLoadersForGlobalCss(mergedOptions, includePaths), { loader: require.resolve('stylus-loader'), options: { - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, stylusOptions: { include: includePaths, }, @@ -226,18 +277,18 @@ export function withWeb() { { test: /\.css$/, include: globalStylePaths, - use: getCommonLoadersForGlobalStyle(options, includePaths), + use: getCommonLoadersForGlobalStyle(mergedOptions, includePaths), }, { test: /\.scss$|\.sass$/, include: globalStylePaths, use: [ - ...getCommonLoadersForGlobalStyle(options, includePaths), + ...getCommonLoadersForGlobalStyle(mergedOptions, includePaths), { loader: require.resolve('sass-loader'), options: { implementation: require('sass'), - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, sassOptions: { fiber: false, // bootstrap-sass requires a minimum precision of 8 @@ -252,11 +303,11 @@ export function withWeb() { test: /\.less$/, include: globalStylePaths, use: [ - ...getCommonLoadersForGlobalStyle(options, includePaths), + ...getCommonLoadersForGlobalStyle(mergedOptions, includePaths), { loader: require.resolve('less-loader'), options: { - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, lessOptions: { javascriptEnabled: true, ...lessPathOptions, @@ -269,11 +320,11 @@ export function withWeb() { test: /\.styl$/, include: globalStylePaths, use: [ - ...getCommonLoadersForGlobalStyle(options, includePaths), + ...getCommonLoadersForGlobalStyle(mergedOptions, includePaths), { loader: require.resolve('stylus-loader'), options: { - sourceMap: !!options.sourceMap, + sourceMap: !!mergedOptions.sourceMap, stylusOptions: { include: includePaths, }, @@ -299,7 +350,7 @@ export function withWeb() { config.output = { ...config.output, - crossOriginLoading: options.subresourceIntegrity + crossOriginLoading: mergedOptions.subresourceIntegrity ? ('anonymous' as const) : (false as const), }; @@ -319,16 +370,16 @@ export function withWeb() { minimizer, emitOnErrors: false, moduleIds: 'deterministic' as const, - runtimeChunk: options.runtimeChunk ? ('single' as const) : false, + runtimeChunk: mergedOptions.runtimeChunk ? ('single' as const) : false, splitChunks: { maxAsyncRequests: Infinity, cacheGroups: { - default: !!options.commonChunk && { + default: !!mergedOptions.commonChunk && { chunks: 'async' as const, minChunks: 2, priority: 10, }, - common: !!options.commonChunk && { + common: !!mergedOptions.commonChunk && { name: 'common', chunks: 'async' as const, minChunks: 2, @@ -336,7 +387,7 @@ export function withWeb() { priority: 5, }, vendors: false as const, - vendor: !!options.vendorChunk && { + vendor: !!mergedOptions.vendorChunk && { name: 'vendor', chunks: (chunk) => chunk.name === 'main', enforce: true, @@ -381,9 +432,7 @@ export function withWeb() { }; } -function createScriptsPlugin( - options: NormalizedWebpackExecutorOptions -): WebpackPluginInstance[] { +function createScriptsPlugin(options: MergedOptions): WebpackPluginInstance[] { // process global scripts const globalScriptsByBundleName = normalizeExtraEntryPoints( options.scripts || [], @@ -433,7 +482,7 @@ function createScriptsPlugin( } function getCommonLoadersForCssModules( - options: NormalizedWebpackExecutorOptions, + options: MergedOptions, includePaths: string[] ) { // load component css as raw strings @@ -467,7 +516,7 @@ function getCommonLoadersForCssModules( } function getCommonLoadersForGlobalCss( - options: NormalizedWebpackExecutorOptions, + options: MergedOptions, includePaths: string[] ) { return [ @@ -488,7 +537,7 @@ function getCommonLoadersForGlobalCss( } function getCommonLoadersForGlobalStyle( - options: NormalizedWebpackExecutorOptions, + options: MergedOptions, includePaths: string[] ) { return [ @@ -508,7 +557,8 @@ function getCommonLoadersForGlobalStyle( } function postcssOptionsCreator( - options: NormalizedWebpackExecutorOptions, + options: MergedOptions, + { includePaths, forCssModules = false,