From e04a620e67531661b79e30a867879494142ffff7 Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Fri, 9 Dec 2016 20:06:54 +0000 Subject: [PATCH] feat(build): add lazy styles/scripts (#3402) Close #3401 Close #3400 --- package.json | 2 +- packages/angular-cli/lib/config/schema.json | 46 +++++- .../json-schema/schema-class-factory.ts | 1 + .../models/json-schema/schema-tree.ts | 17 +- .../models/webpack-build-common.ts | 146 +++++++++--------- .../models/webpack-build-development.ts | 42 +---- .../models/webpack-build-production.ts | 52 +++---- .../angular-cli/models/webpack-build-utils.ts | 83 +++++++++- packages/angular-cli/models/webpack-config.ts | 8 +- packages/angular-cli/package.json | 2 +- .../suppress-entry-chunks-webpack-plugin.ts | 44 ++++++ tests/e2e/tests/build/prod-build.ts | 2 +- tests/e2e/tests/build/scripts-array.ts | 52 +++++++ tests/e2e/tests/build/styles/css.ts | 30 ++++ tests/e2e/tests/build/styles/less.ts | 39 +++-- tests/e2e/tests/build/styles/postcss.ts | 25 +++ tests/e2e/tests/build/styles/scss.ts | 49 +++--- tests/e2e/tests/build/styles/styles-array.ts | 100 ++++++------ tests/e2e/tests/build/styles/stylus.ts | 40 +++++ tests/e2e/tests/third-party/bootstrap.ts | 7 +- 20 files changed, 532 insertions(+), 255 deletions(-) create mode 100644 packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts create mode 100644 tests/e2e/tests/build/scripts-array.ts create mode 100644 tests/e2e/tests/build/styles/css.ts create mode 100644 tests/e2e/tests/build/styles/postcss.ts create mode 100644 tests/e2e/tests/build/styles/stylus.ts diff --git a/package.json b/package.json index d539d1f2e9a3..d79dd5e20593 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "resolve": "^1.1.7", "rimraf": "^2.5.3", "rsvp": "^3.0.17", - "sass-loader": "^3.2.0", + "sass-loader": "^4.0.1", "script-loader": "^0.7.0", "semver": "^5.1.0", "silent-error": "^1.0.0", diff --git a/packages/angular-cli/lib/config/schema.json b/packages/angular-cli/lib/config/schema.json index 37668441c0b0..2334acd76427 100644 --- a/packages/angular-cli/lib/config/schema.json +++ b/packages/angular-cli/lib/config/schema.json @@ -32,11 +32,17 @@ "default": "dist/" }, "assets": { - "fixme": true, - "type": "array", - "items": { - "type": "string" - }, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], "default": [] }, "index": { @@ -62,7 +68,20 @@ "description": "Global styles to be included in the build.", "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": true + } + ] }, "additionalProperties": false }, @@ -70,7 +89,20 @@ "description": "Global scripts to be included in the build.", "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": true + } + ] }, "additionalProperties": false }, diff --git a/packages/angular-cli/models/json-schema/schema-class-factory.ts b/packages/angular-cli/models/json-schema/schema-class-factory.ts index c6b0734bc8e0..9af3b537031d 100644 --- a/packages/angular-cli/models/json-schema/schema-class-factory.ts +++ b/packages/angular-cli/models/json-schema/schema-class-factory.ts @@ -46,6 +46,7 @@ function _parseJsonPath(path: string): string[] { function _getSchemaNodeForPath(rootMetaData: SchemaTreeNode, path: string): SchemaTreeNode { let fragments = _parseJsonPath(path); + // TODO: make this work with union (oneOf) schemas return fragments.reduce((md: SchemaTreeNode, current: string) => { return md && md.children && md.children[current]; }, rootMetaData); diff --git a/packages/angular-cli/models/json-schema/schema-tree.ts b/packages/angular-cli/models/json-schema/schema-tree.ts index 9ea195e579b4..eb13c61f5d57 100644 --- a/packages/angular-cli/models/json-schema/schema-tree.ts +++ b/packages/angular-cli/models/json-schema/schema-tree.ts @@ -129,12 +129,21 @@ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { protected _createChildProperty(name: string, value: T, forward: SchemaTreeNode, schema: Schema, define = true): SchemaTreeNode { - // TODO: fix this - if (schema['fixme'] && typeof value === 'string') { - value = ([ value ]); + let type: string; + + if (!schema['oneOf']) { + type = schema['type']; + } else { + for (let testSchema of schema['oneOf']) { + if ((testSchema['type'] === 'array' && Array.isArray(value)) + || typeof value === testSchema['type']) { + type = testSchema['type']; + schema = testSchema; + break; + } + } } - const type = schema['type']; let Klass: any = null; switch (type) { diff --git a/packages/angular-cli/models/webpack-build-common.ts b/packages/angular-cli/models/webpack-build-common.ts index a226f7ef051d..bc3f5cda9439 100644 --- a/packages/angular-cli/models/webpack-build-common.ts +++ b/packages/angular-cli/models/webpack-build-common.ts @@ -1,10 +1,13 @@ import * as webpack from 'webpack'; import * as path from 'path'; -import {GlobCopyWebpackPlugin} from '../plugins/glob-copy-webpack-plugin'; -import {packageChunkSort} from '../utilities/package-chunk-sort'; -import {BaseHrefWebpackPlugin} from '@angular-cli/base-href-webpack'; +import { GlobCopyWebpackPlugin } from '../plugins/glob-copy-webpack-plugin'; +import { SuppressEntryChunksWebpackPlugin } from '../plugins/suppress-entry-chunks-webpack-plugin'; +import { packageChunkSort } from '../utilities/package-chunk-sort'; +import { BaseHrefWebpackPlugin } from '@angular-cli/base-href-webpack'; +import { extraEntryParser, makeCssLoaders } from './webpack-build-utils'; -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); +const autoprefixer = require('autoprefixer'); +const ProgressPlugin = require('webpack/lib/ProgressPlugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const SilentError = require('silent-error'); @@ -14,21 +17,12 @@ const SilentError = require('silent-error'); * * require('source-map-loader') * require('raw-loader') - * require('postcss-loader') - * require('stylus-loader') - * require('less-loader') - * require('sass-loader') * require('script-loader') * require('json-loader') * require('url-loader') * require('file-loader') - * - * require('node-sass') - * require('less') - * require('stylus') */ - export function getWebpackCommonConfig( projectRoot: string, environment: string, @@ -43,15 +37,12 @@ export function getWebpackCommonConfig( const appRoot = path.resolve(projectRoot, appConfig.root); const appMain = path.resolve(appRoot, appConfig.main); const nodeModules = path.resolve(projectRoot, 'node_modules'); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const scripts = appConfig.scripts - ? appConfig.scripts.map((script: string) => path.resolve(appRoot, script)) - : []; - const extraPlugins: any[] = []; - - let entry: { [key: string]: string[] } = { + + let extraPlugins: any[] = []; + let extraRules: any[] = []; + let lazyChunks: string[] = []; + + let entryPoints: { [key: string]: string[] } = { main: [appMain] }; @@ -59,9 +50,51 @@ export function getWebpackCommonConfig( throw new SilentError(`Environment "${environment}" does not exist.`); } - // Only add styles/scripts if there's actually entries there - if (appConfig.styles.length > 0) { entry['styles'] = styles; } - if (appConfig.scripts.length > 0) { entry['scripts'] = scripts; } + // process global scripts + if (appConfig.scripts.length > 0) { + const globalScrips = extraEntryParser(appConfig.scripts, appRoot, 'scripts'); + + // add entry points and lazy chunks + globalScrips.forEach(script => { + if (script.lazy) { lazyChunks.push(script.entry); } + entryPoints[script.entry] = (entryPoints[script.entry] || []).concat(script.path); + }); + + // load global scripts using script-loader + extraRules.push({ + include: globalScrips.map((script) => script.path), test: /\.js$/, loader: 'script-loader' + }); + } + + // process global styles + if (appConfig.styles.length === 0) { + // create css loaders for component css + extraRules.push(...makeCssLoaders()); + } else { + const globalStyles = extraEntryParser(appConfig.styles, appRoot, 'styles'); + let extractedCssEntryPoints: string[] = []; + // add entry points and lazy chunks + globalStyles.forEach(style => { + if (style.lazy) { lazyChunks.push(style.entry); } + if (!entryPoints[style.entry]) { + // since this entry point doesn't exist yet, it's going to only have + // extracted css and we can supress the entry point + extractedCssEntryPoints.push(style.entry); + entryPoints[style.entry] = (entryPoints[style.entry] || []).concat(style.path); + } else { + // existing entry point, just push the css in + entryPoints[style.entry].push(style.path); + } + }); + + // create css loaders for component css and for global css + extraRules.push(...makeCssLoaders(globalStyles.map((style) => style.path))); + + if (extractedCssEntryPoints.length > 0) { + // don't emit the .js entry point for extracted styles + extraPlugins.push(new SuppressEntryChunksWebpackPlugin({ chunks: extractedCssEntryPoints })); + } + } if (vendorChunk) { extraPlugins.push(new webpack.optimize.CommonsChunkPlugin({ @@ -71,12 +104,7 @@ export function getWebpackCommonConfig( })); } - if (progress) { - extraPlugins.push(new ProgressPlugin({ - profile: verbose, - colors: true - })); - } + if (progress) { extraPlugins.push(new ProgressPlugin({ profile: verbose, colors: true })); } return { devtool: sourcemap ? 'source-map' : false, @@ -85,10 +113,10 @@ export function getWebpackCommonConfig( modules: [nodeModules], }, resolveLoader: { - modules: [path.resolve(projectRoot, 'node_modules')] + modules: [nodeModules] }, context: projectRoot, - entry: entry, + entry: entryPoints, output: { path: path.resolve(projectRoot, appConfig.outDir), filename: '[name].bundle.js', @@ -97,48 +125,22 @@ export function getWebpackCommonConfig( }, module: { rules: [ - { - enforce: 'pre', - test: /\.js$/, - loader: 'source-map-loader', - exclude: [ nodeModules ] - }, - // in main, load css as raw text -        { - exclude: styles, - test: /\.css$/, - loaders: ['raw-loader', 'postcss-loader'] - }, { - exclude: styles, - test: /\.styl$/, - loaders: ['raw-loader', 'postcss-loader', 'stylus-loader'] }, -        { - exclude: styles, - test: /\.less$/, - loaders: ['raw-loader', 'postcss-loader', 'less-loader'] - }, { - exclude: styles, - test: /\.scss$|\.sass$/, - loaders: ['raw-loader', 'postcss-loader', 'sass-loader'] - }, - - - // load global scripts using script-loader - { include: scripts, test: /\.js$/, loader: 'script-loader' }, + { enforce: 'pre', test: /\.js$/, loader: 'source-map-loader', exclude: [nodeModules] }, -        { test: /\.json$/, loader: 'json-loader' }, -        { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, -        { test: /\.html$/, loader: 'raw-loader' }, + { test: /\.json$/, loader: 'json-loader' }, + { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, + { test: /\.html$/, loader: 'raw-loader' }, { test: /\.(otf|ttf|woff|woff2)$/, loader: 'url-loader?limit=10000' }, { test: /\.(eot|svg)$/, loader: 'file-loader' } - ] + ].concat(extraRules) }, plugins: [ new HtmlWebpackPlugin({ template: path.resolve(appRoot, appConfig.index), filename: path.resolve(appConfig.outDir, appConfig.index), - chunksSortMode: packageChunkSort(['inline', 'styles', 'scripts', 'vendor', 'main']) + chunksSortMode: packageChunkSort(['inline', 'styles', 'scripts', 'vendor', 'main']), + excludeChunks: lazyChunks }), new BaseHrefWebpackPlugin({ baseHref: baseHref @@ -157,14 +159,18 @@ export function getWebpackCommonConfig( }), new GlobCopyWebpackPlugin({ patterns: appConfig.assets, - globOptions: {cwd: appRoot, dot: true, ignore: '**/.gitkeep'} + globOptions: { cwd: appRoot, dot: true, ignore: '**/.gitkeep' } }), new webpack.LoaderOptionsPlugin({ test: /\.(css|scss|sass|less|styl)$/, options: { - postcss: [ - require('autoprefixer') - ] + postcss: [autoprefixer()], + cssLoader: { sourceMap: sourcemap }, + sassLoader: { sourceMap: sourcemap }, + lessLoader: { sourceMap: sourcemap }, + stylusLoader: { sourceMap: sourcemap }, + // context needed as a workaround https://github.com/jtangelder/sass-loader/issues/285 + context: projectRoot, }, }) ].concat(extraPlugins), diff --git a/packages/angular-cli/models/webpack-build-development.ts b/packages/angular-cli/models/webpack-build-development.ts index cb0229af6066..851824f13473 100644 --- a/packages/angular-cli/models/webpack-build-development.ts +++ b/packages/angular-cli/models/webpack-build-development.ts @@ -1,22 +1,9 @@ const path = require('path'); - -/** - * Enumerate loaders and their dependencies from this file to let the dependency validator - * know they are used. - * - * require('style-loader') - * require('css-loader') - * require('stylus-loader') - * require('less-loader') - * require('sass-loader') - */ +const ExtractTextPlugin = require('extract-text-webpack-plugin'); export const getWebpackDevConfigPartial = function(projectRoot: string, appConfig: any) { const appRoot = path.resolve(projectRoot, appConfig.root); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const cssLoaders = ['style-loader', 'css-loader?sourcemap', 'postcss-loader']; + return { output: { path: path.resolve(projectRoot, appConfig.outDir), @@ -24,27 +11,8 @@ export const getWebpackDevConfigPartial = function(projectRoot: string, appConfi sourceMapFilename: '[name].bundle.map', chunkFilename: '[id].chunk.js' }, - module: { - rules: [ - // outside of main, load it via style-loader for development builds -        { - include: styles, - test: /\.css$/, - loaders: cssLoaders - }, { - include: styles, - test: /\.styl$/, - loaders: [...cssLoaders, 'stylus-loader?sourcemap'] - }, { - include: styles, - test: /\.less$/, - loaders: [...cssLoaders, 'less-loader?sourcemap'] - }, { - include: styles, - test: /\.scss$|\.sass$/, - loaders: [...cssLoaders, 'sass-loader?sourcemap'] - }, - ] - } + plugins: [ + new ExtractTextPlugin({filename: '[name].bundle.css'}) + ] }; }; diff --git a/packages/angular-cli/models/webpack-build-production.ts b/packages/angular-cli/models/webpack-build-production.ts index a1f3963286df..21bced337c7b 100644 --- a/packages/angular-cli/models/webpack-build-production.ts +++ b/packages/angular-cli/models/webpack-build-production.ts @@ -1,9 +1,10 @@ import * as path from 'path'; -import {CompressionPlugin} from '../lib/webpack/compression-plugin'; - -const WebpackMd5Hash = require('webpack-md5-hash'); import * as webpack from 'webpack'; +const WebpackMd5Hash = require('webpack-md5-hash'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); +import {CompressionPlugin} from '../lib/webpack/compression-plugin'; +const autoprefixer = require('autoprefixer'); +const postcssDiscardComments = require('postcss-discard-comments'); declare module 'webpack' { export interface LoaderOptionsPlugin {} @@ -17,12 +18,9 @@ declare module 'webpack' { export const getWebpackProdConfigPartial = function(projectRoot: string, appConfig: any, + sourcemap: boolean, verbose: any) { const appRoot = path.resolve(projectRoot, appConfig.root); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const cssLoaders = ['css-loader?sourcemap&minimize', 'postcss-loader']; return { output: { @@ -31,38 +29,17 @@ export const getWebpackProdConfigPartial = function(projectRoot: string, sourceMapFilename: '[name].[chunkhash].bundle.map', chunkFilename: '[id].[chunkhash].chunk.js' }, - module: { - rules: [ - // outside of main, load it via extract-text-plugin for production builds -        { - include: styles, - test: /\.css$/, - loaders: ExtractTextPlugin.extract(cssLoaders) - }, { - include: styles, - test: /\.styl$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'stylus-loader?sourcemap']) - }, { - include: styles, - test: /\.less$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'less-loader?sourcemap']) - }, { - include: styles, - test: /\.scss$|\.sass$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'sass-loader?sourcemap']) - }, - ] - }, plugins: [ - new ExtractTextPlugin('[name].[contenthash].bundle.css'), + new ExtractTextPlugin('[name].[chunkhash].bundle.css'), new WebpackMd5Hash(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }), + new webpack.LoaderOptionsPlugin({ minimize: true }), new webpack.optimize.UglifyJsPlugin({ mangle: { screw_ie8 : true }, compress: { screw_ie8: true, warnings: verbose }, - sourceMap: true + sourceMap: sourcemap }), new CompressionPlugin({ asset: '[path].gz[query]', @@ -70,13 +47,20 @@ export const getWebpackProdConfigPartial = function(projectRoot: string, test: /\.js$|\.html$|\.css$/, threshold: 10240 }), + // LoaderOptionsPlugin needs to be fully duplicated because webpackMerge will replace it. new webpack.LoaderOptionsPlugin({ test: /\.(css|scss|sass|less|styl)$/, options: { postcss: [ - require('autoprefixer'), - require('postcss-discard-comments') - ] + autoprefixer(), + postcssDiscardComments + ], + cssLoader: { sourceMap: sourcemap }, + sassLoader: { sourceMap: sourcemap }, + lessLoader: { sourceMap: sourcemap }, + stylusLoader: { sourceMap: sourcemap }, + // context needed as a workaround https://github.com/jtangelder/sass-loader/issues/285 + context: projectRoot, } }) ] diff --git a/packages/angular-cli/models/webpack-build-utils.ts b/packages/angular-cli/models/webpack-build-utils.ts index b2aa2148800e..13fd6eedee51 100644 --- a/packages/angular-cli/models/webpack-build-utils.ts +++ b/packages/angular-cli/models/webpack-build-utils.ts @@ -1,4 +1,22 @@ import * as path from 'path'; +const ExtractTextPlugin = require('extract-text-webpack-plugin'); + +/** + * Enumerate loaders and their dependencies from this file to let the dependency validator + * know they are used. + * + * require('raw-loader') + * require('style-loader') + * require('postcss-loader') + * require('css-loader') + * require('stylus-loader') + * require('less-loader') + * require('sass-loader') + * + * require('node-sass') + * require('less') + * require('stylus') + */ export const ngAppResolve = (resolvePath: string): string => { return path.resolve(process.cwd(), resolvePath); @@ -27,7 +45,66 @@ const verboseWebpackOutputOptions = { }; export function getWebpackStatsConfig(verbose = false) { - return verbose - ? Object.assign(webpackOutputOptions, verboseWebpackOutputOptions) - : webpackOutputOptions; + return verbose + ? Object.assign(webpackOutputOptions, verboseWebpackOutputOptions) + : webpackOutputOptions; +} + +export interface ExtraEntry { + input: string; + output?: string; + lazy?: boolean; + path?: string; + entry?: string; +} + +// create array of css loaders +export function makeCssLoaders(stylePaths: string[] = []) { + const baseRules = [ + { test: /\.css$/, loaders: [] }, + { test: /\.scss$|\.sass$/, loaders: ['sass-loader'] }, + { test: /\.less$/, loaders: ['less-loader'] }, + { test: /\.styl$/, loaders: ['stylus-loader'] } + ]; + + const commonLoaders = ['css-loader', 'postcss-loader']; + + // load component css as raw strings + let cssLoaders: any = baseRules.map(({test, loaders}) => ({ + exclude: stylePaths, test, loaders: ['raw-loader', ...commonLoaders, ...loaders] + })); + + if (stylePaths.length > 0) { + // load global css as css files + cssLoaders.push(...baseRules.map(({test, loaders}) => ({ + include: stylePaths, test, loaders: ExtractTextPlugin.extract({ + loader: [...commonLoaders, ...loaders], + fallbackLoader: 'style-loader' + }) + }))); + } + + return cssLoaders; +} + +// convert all extra entries into the object representation, fill in defaults +export function extraEntryParser( + extraEntries: (string | ExtraEntry)[], + appRoot: string, + defaultEntry: string +): ExtraEntry[] { + return extraEntries + .map((extraEntry: string | ExtraEntry) => + typeof extraEntry === 'string' ? { input: extraEntry } : extraEntry) + .map((extraEntry: ExtraEntry) => { + extraEntry.path = path.resolve(appRoot, extraEntry.input); + if (extraEntry.output) { + extraEntry.entry = extraEntry.output.replace(/\.(js|css)$/i, ''); + } else if (extraEntry.lazy) { + extraEntry.entry = extraEntry.input.replace(/\.(js|css|scss|sass|less|styl)$/i, ''); + } else { + extraEntry.entry = defaultEntry; + } + return extraEntry; + }); } diff --git a/packages/angular-cli/models/webpack-config.ts b/packages/angular-cli/models/webpack-config.ts index 2b9b65b56c2c..439e8e7e88e6 100644 --- a/packages/angular-cli/models/webpack-config.ts +++ b/packages/angular-cli/models/webpack-config.ts @@ -45,7 +45,9 @@ export class NgCliWebpackConfig { verbose, progress ); - let targetConfigPartial = this.getTargetConfig(this.ngCliProject.root, appConfig, verbose); + let targetConfigPartial = this.getTargetConfig( + this.ngCliProject.root, appConfig, sourcemap, verbose + ); const typescriptConfigPartial = isAoT ? getWebpackAotConfigPartial(this.ngCliProject.root, appConfig) : getWebpackNonAotConfigPartial(this.ngCliProject.root, appConfig); @@ -67,12 +69,12 @@ export class NgCliWebpackConfig { ); } - getTargetConfig(projectRoot: string, appConfig: any, verbose: boolean): any { + getTargetConfig(projectRoot: string, appConfig: any, sourcemap: boolean, verbose: boolean): any { switch (this.target) { case 'development': return getWebpackDevConfigPartial(projectRoot, appConfig); case 'production': - return getWebpackProdConfigPartial(projectRoot, appConfig, verbose); + return getWebpackProdConfigPartial(projectRoot, appConfig, sourcemap, verbose); default: throw new Error("Invalid build target. Only 'development' and 'production' are available."); } diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json index 84c6909bfa7f..c554acd35c3b 100644 --- a/packages/angular-cli/package.json +++ b/packages/angular-cli/package.json @@ -82,7 +82,7 @@ "resolve": "^1.1.7", "rimraf": "^2.5.3", "rsvp": "^3.0.17", - "sass-loader": "^3.2.0", + "sass-loader": "^4.0.1", "script-loader": "^0.7.0", "semver": "^5.1.0", "silent-error": "^1.0.0", diff --git a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts new file mode 100644 index 000000000000..0d62fc4f3ee3 --- /dev/null +++ b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts @@ -0,0 +1,44 @@ +// ExtractTextPlugin leaves behind the entry points, which we might not need anymore +// if they were entirely css. This plugin removes those entry points. + +export interface SuppressEntryChunksWebpackPluginOptions { + chunks: string[]; +} + +export class SuppressEntryChunksWebpackPlugin { + constructor(private options: SuppressEntryChunksWebpackPluginOptions) { } + + apply(compiler: any): void { + let { chunks } = this.options; + compiler.plugin('compilation', function (compilation: any) { + // Remove the js file for supressed chunks + compilation.plugin('after-seal', (callback: any) => { + compilation.chunks + .filter((chunk: any) => chunks.indexOf(chunk.name) !== -1) + .forEach((chunk: any) => { + let newFiles: string[] = []; + chunk.files.forEach((file: string) => { + if (file.match(/\.js$/)) { + // remove js files + delete compilation.assets[file]; + } else { + newFiles.push(file); + } + }); + chunk.files = newFiles; + }); + callback(); + }); + // Remove scripts tags with a css file as source, because HtmlWebpackPlugin will use + // a css file as a script for chunks without js files. + compilation.plugin('html-webpack-plugin-alter-asset-tags', + (htmlPluginData: any, callback: any) => { + const filterFn = (tag: any) => + !(tag.tagName === 'script' && tag.attributes.src.match(/\.css$/)); + htmlPluginData.head = htmlPluginData.head.filter(filterFn); + htmlPluginData.body = htmlPluginData.body.filter(filterFn); + callback(null, htmlPluginData); + }); + }); + } +} diff --git a/tests/e2e/tests/build/prod-build.ts b/tests/e2e/tests/build/prod-build.ts index f4d798b70485..0e9074a24dc0 100644 --- a/tests/e2e/tests/build/prod-build.ts +++ b/tests/e2e/tests/build/prod-build.ts @@ -34,7 +34,7 @@ export default function() { .then(() => expectFileToExist(join(process.cwd(), 'dist'))) // Check for cache busting hash script src .then(() => expectFileToMatch('dist/index.html', /main\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{32}\.bundle\.css/)) + .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{20}\.bundle\.css/)) // Check that the process didn't change local files. .then(() => expectGitToBeClean()) diff --git a/tests/e2e/tests/build/scripts-array.ts b/tests/e2e/tests/build/scripts-array.ts new file mode 100644 index 000000000000..995a75381720 --- /dev/null +++ b/tests/e2e/tests/build/scripts-array.ts @@ -0,0 +1,52 @@ +import { + writeMultipleFiles, + expectFileToMatch +} from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { oneLineTrim } from 'common-tags'; + +export default function () { + return writeMultipleFiles({ + 'src/string-script.js': 'console.log(\'string-script\');', + 'src/input-script.js': 'console.log(\'input-script\');', + 'src/lazy-script.js': 'console.log(\'lazy-script\');', + 'src/pre-rename-script.js': 'console.log(\'pre-rename-script\');', + 'src/pre-rename-lazy-script.js': 'console.log(\'pre-rename-lazy-script\');', + 'src/common-entry-script.js': 'console.log(\'common-entry-script\');', + 'src/common-entry-style.css': '.common-entry-style { color: red }', + }) + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['scripts'] = [ + 'string-script.js', + { input: 'input-script.js' }, + { input: 'lazy-script.js', lazy: true }, + { input: 'pre-rename-script.js', output: 'renamed-script' }, + { input: 'pre-rename-lazy-script.js', output: 'renamed-lazy-script', lazy: true }, + { input: 'common-entry-script.js', output: 'common-entry' } + ]; + app['styles'] = [{ input: 'common-entry-style.css', output: 'common-entry' }]; + })) + .then(() => ng('build')) + // files were created successfully + .then(() => expectFileToMatch('dist/scripts.bundle.js', 'string-script')) + .then(() => expectFileToMatch('dist/scripts.bundle.js', 'input-script')) + .then(() => expectFileToMatch('dist/lazy-script.bundle.js', 'lazy-script')) + .then(() => expectFileToMatch('dist/renamed-script.bundle.js', 'pre-rename-script')) + .then(() => expectFileToMatch('dist/renamed-lazy-script.bundle.js', 'pre-rename-lazy-script')) + .then(() => expectFileToMatch('dist/common-entry.bundle.js', 'common-entry-script')) + .then(() => expectFileToMatch('dist/common-entry.bundle.css', '.common-entry-style')) + // index.html lists the right bundles + .then(() => expectFileToMatch('dist/index.html', oneLineTrim` + + `)) + .then(() => expectFileToMatch('dist/index.html', oneLineTrim` + + + + + + + `)); +} diff --git a/tests/e2e/tests/build/styles/css.ts b/tests/e2e/tests/build/styles/css.ts new file mode 100644 index 000000000000..f1abac5b37f0 --- /dev/null +++ b/tests/e2e/tests/build/styles/css.ts @@ -0,0 +1,30 @@ +import { + writeMultipleFiles, + expectFileToMatch, +} from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { stripIndents } from 'common-tags'; + +export default function () { + return writeMultipleFiles({ + 'src/styles.css': stripIndents` + @import './imported-styles.css'; + body { background-color: blue; } + `, + 'src/imported-styles.css': stripIndents` + p { background-color: red; } + `, + 'src/app/app.component.css': stripIndents` + .outer { + .inner { + background: #fff; + } + } + `}) + .then(() => ng('build')) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /body\s*{\s*background-color: blue;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /p\s*{\s*background-color: red;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); +} diff --git a/tests/e2e/tests/build/styles/less.ts b/tests/e2e/tests/build/styles/less.ts index 279742be2ceb..edb197b9997a 100644 --- a/tests/e2e/tests/build/styles/less.ts +++ b/tests/e2e/tests/build/styles/less.ts @@ -2,32 +2,39 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - moveFile, replaceInFile } from '../../../utils/fs'; -import {ng} from '../../../utils/process'; -import {stripIndents} from 'common-tags'; -import {isMobileTest} from '../../../utils/utils'; - - -export default function() { - if (isMobileTest()) { - return; - } +import { ng } from '../../../utils/process'; +import { stripIndents } from 'common-tags'; +import { updateJsonFile } from '../../../utils/project'; +export default function () { return writeMultipleFiles({ - 'src/app/app.component.less': stripIndents` + 'src/styles.less': stripIndents` + @import './imported-styles.less'; + body { background-color: blue; } + `, + 'src/imported-styles.less': stripIndents` + p { background-color: red; } + `, + 'src/app/app.component.less': stripIndents` .outer { .inner { background: #fff; } } - ` - }) + `}) .then(() => deleteFile('src/app/app.component.css')) + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['styles'] = ['styles.less']; + })) .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.less')) + './app.component.css', './app.component.less')) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)) - .then(() => moveFile('src/app/app.component.less', 'src/app/app.component.css')); + .then(() => expectFileToMatch('dist/styles.bundle.css', + /body\s*{\s*background-color: blue;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /p\s*{\s*background-color: red;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); } diff --git a/tests/e2e/tests/build/styles/postcss.ts b/tests/e2e/tests/build/styles/postcss.ts new file mode 100644 index 000000000000..809ce7919f71 --- /dev/null +++ b/tests/e2e/tests/build/styles/postcss.ts @@ -0,0 +1,25 @@ +import * as glob from 'glob'; +import { writeFile, expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { stripIndents } from 'common-tags'; + +export default function () { + return writeFile('src/styles.css', stripIndents` + /* normal-comment */ + /*! important-comment */ + div { flex: 1 } + `) + // uses autoprefixer plugin for all builds + .then(() => ng('build')) + .then(() => expectFileToMatch('dist/styles.bundle.css', stripIndents` + /* normal-comment */ + /*! important-comment */ + div { -webkit-box-flex: 1; -ms-flex: 1; flex: 1 } + `)) + // uses postcss-discard-comments plugin for prod + .then(() => ng('build', '--prod')) + .then(() => glob.sync('dist/styles.*.bundle.css').find(file => !!file)) + .then((stylesBundle) => expectFileToMatch(stylesBundle, stripIndents` + /*! important-comment */div{-webkit-box-flex:1;-ms-flex:1;flex:1} + `)); +} diff --git a/tests/e2e/tests/build/styles/scss.ts b/tests/e2e/tests/build/styles/scss.ts index 52d7d46fbd42..9ba76e715617 100644 --- a/tests/e2e/tests/build/styles/scss.ts +++ b/tests/e2e/tests/build/styles/scss.ts @@ -2,40 +2,39 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - moveFile, replaceInFile } from '../../../utils/fs'; -import {ng} from '../../../utils/process'; -import {stripIndents} from 'common-tags'; -import {isMobileTest} from '../../../utils/utils'; - - -export default function() { - if (isMobileTest()) { - return; - } +import { ng } from '../../../utils/process'; +import { stripIndents } from 'common-tags'; +import { updateJsonFile } from '../../../utils/project'; +export default function () { return writeMultipleFiles({ - 'src/app/app.component.scss': stripIndents` - @import "app.component.partial"; - + 'src/styles.scss': stripIndents` + @import './imported-styles.scss'; + body { background-color: blue; } + `, + 'src/imported-styles.scss': stripIndents` + p { background-color: red; } + `, + 'src/app/app.component.scss': stripIndents` .outer { .inner { - background: #def; + background: #fff; } } - `, - 'src/app/app.component.partial.scss': stripIndents` - .partial { - @extend .outer; - } - ` - }) + `}) .then(() => deleteFile('src/app/app.component.css')) + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['styles'] = ['styles.scss']; + })) .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) + './app.component.css', './app.component.scss')) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/main.bundle.js', /\.outer.*\.inner.*background.*#def/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /\.partial.*\.inner.*background.*#def/)) - .then(() => moveFile('src/app/app.component.scss', 'src/app/app.component.css')); + .then(() => expectFileToMatch('dist/styles.bundle.css', + /body\s*{\s*background-color: blue;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /p\s*{\s*background-color: red;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); } diff --git a/tests/e2e/tests/build/styles/styles-array.ts b/tests/e2e/tests/build/styles/styles-array.ts index 184adc165f78..ff913bd63afd 100644 --- a/tests/e2e/tests/build/styles/styles-array.ts +++ b/tests/e2e/tests/build/styles/styles-array.ts @@ -1,57 +1,59 @@ -import * as glob from 'glob'; +import { + writeMultipleFiles, + expectFileToExist, + expectFileToMatch +} from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; +import { oneLineTrim } from 'common-tags'; -import {writeMultipleFiles, expectFileToMatch} from '../../../utils/fs'; -import {ng} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; - - -export default function() { +export default function () { return writeMultipleFiles({ - 'src/styles.css': ` - @import './imported-styles.css'; - - body { background-color: blue; } - - div { flex: 1 } - `, - 'src/imported-styles.css': ` - p { background-color: red; } - `, - 'src/styles.less': ` - .outer { - .inner { - background: #fff; - } - } - `, - 'src/styles.scss': ` - .upper { - .lower { - background: #def; - } - } - ` + 'src/string-style.css': '.string-style { color: red }', + 'src/input-style.css': '.input-style { color: red }', + 'src/lazy-style.css': '.lazy-style { color: red }', + 'src/pre-rename-style.css': '.pre-rename-style { color: red }', + 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', + 'src/common-entry-style.css': '.common-entry-style { color: red }', + 'src/common-entry-script.js': 'console.log(\'common-entry-script\');' }) .then(() => updateJsonFile('angular-cli.json', configJson => { const app = configJson['apps'][0]; - app['styles'].push('styles.less'); - app['styles'].push('styles.scss'); + app['styles'] = [ + 'string-style.css', + { input: 'input-style.css' }, + { input: 'lazy-style.css', lazy: true }, + { input: 'pre-rename-style.css', output: 'renamed-style' }, + { input: 'pre-rename-lazy-style.css', output: 'renamed-lazy-style', lazy: true }, + { input: 'common-entry-style.css', output: 'common-entry' } + ]; + app['scripts'] = [{ input: 'common-entry-script.js', output: 'common-entry' }]; })) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.js', 'body { background-color: blue; }')) - .then(() => expectFileToMatch('dist/styles.bundle.js', 'p { background-color: red; }')) - .then(() => expectFileToMatch( - 'dist/styles.bundle.js', - 'div { -webkit-box-flex: 1; -ms-flex: 1; flex: 1 }')) - .then(() => expectFileToMatch('dist/styles.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)) - .then(() => expectFileToMatch('dist/styles.bundle.js', /.upper.*.lower.*background.*#def/)) - - .then(() => ng('build', '--prod')) - .then(() => glob.sync('dist/styles.*.bundle.css').find(file => !!file)) - .then((styles) => - expectFileToMatch(styles, /body\s*\{\s*background-color:\s*blue\s*\}/) - .then(() => expectFileToMatch(styles, /p\s*\{\s*background-color:\s*red\s*\}/) - .then(() => expectFileToMatch(styles, /.outer.*.inner.*background:\s*#[fF]+/)) - .then(() => expectFileToMatch(styles, /.upper.*.lower.*background.*#def/))) - ); + // files were created successfully + .then(() => expectFileToMatch('dist/styles.bundle.css', '.string-style')) + .then(() => expectFileToMatch('dist/styles.bundle.css', '.input-style')) + .then(() => expectFileToMatch('dist/lazy-style.bundle.css', '.lazy-style')) + .then(() => expectFileToMatch('dist/renamed-style.bundle.css', '.pre-rename-style')) + .then(() => expectFileToMatch('dist/renamed-lazy-style.bundle.css', '.pre-rename-lazy-style')) + .then(() => expectFileToMatch('dist/common-entry.bundle.css', '.common-entry-style')) + .then(() => expectFileToMatch('dist/common-entry.bundle.js', 'common-entry-script')) + // there are no js entry points for css only bundles + .then(() => expectToFail(() => expectFileToExist('dist/styles.bundle.js'))) + .then(() => expectToFail(() => expectFileToExist('dist/lazy-styles.bundle.js'))) + .then(() => expectToFail(() => expectFileToExist('dist/renamed-styles.bundle.js'))) + .then(() => expectToFail(() => expectFileToExist('dist/renamed-lazy-styles.bundle.js'))) + // index.html lists the right bundles + .then(() => expectFileToMatch('dist/index.html', oneLineTrim` + + + + `)) + .then(() => expectFileToMatch('dist/index.html', oneLineTrim` + + + + + `)); } diff --git a/tests/e2e/tests/build/styles/stylus.ts b/tests/e2e/tests/build/styles/stylus.ts new file mode 100644 index 000000000000..b318dfa73d24 --- /dev/null +++ b/tests/e2e/tests/build/styles/stylus.ts @@ -0,0 +1,40 @@ +import { + writeMultipleFiles, + deleteFile, + expectFileToMatch, + replaceInFile +} from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { stripIndents } from 'common-tags'; +import { updateJsonFile } from '../../../utils/project'; + +export default function () { + return writeMultipleFiles({ + 'src/styles.styl': stripIndents` + @import './imported-styles.styl'; + body { background-color: blue; } + `, + 'src/imported-styles.styl': stripIndents` + p { background-color: red; } + `, + 'src/app/app.component.styl': stripIndents` + .outer { + .inner { + background: #fff; + } + } + `}) + .then(() => deleteFile('src/app/app.component.css')) + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['styles'] = ['styles.styl']; + })) + .then(() => replaceInFile('src/app/app.component.ts', + './app.component.css', './app.component.styl')) + .then(() => ng('build')) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /body\s*{\s*background-color: #00f;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', + /p\s*{\s*background-color: #f00;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); +} diff --git a/tests/e2e/tests/third-party/bootstrap.ts b/tests/e2e/tests/third-party/bootstrap.ts index ca68ae0991f1..23f922440970 100644 --- a/tests/e2e/tests/third-party/bootstrap.ts +++ b/tests/e2e/tests/third-party/bootstrap.ts @@ -17,13 +17,12 @@ export default function() { ); })) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '/*!\\n * jQuery JavaScript')) + .then(() => expectFileToMatch('dist/scripts.bundle.js', '* jQuery JavaScript')) .then(() => expectFileToMatch('dist/scripts.bundle.js', '/*! tether ')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '/*!\\n * Bootstrap')) - .then(() => expectFileToMatch('dist/styles.bundle.js', '/*!\\n * Bootstrap')) + .then(() => expectFileToMatch('dist/scripts.bundle.js', '* Bootstrap')) + .then(() => expectFileToMatch('dist/styles.bundle.css', '* Bootstrap')) .then(() => expectFileToMatch('dist/index.html', oneLineTrim` -