diff --git a/packages/angular-cli/lib/config/schema.json b/packages/angular-cli/lib/config/schema.json index c8626d1aac1d..6f23d6c4e219 100644 --- a/packages/angular-cli/lib/config/schema.json +++ b/packages/angular-cli/lib/config/schema.json @@ -89,6 +89,21 @@ }, "additionalProperties": false }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to project root.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false + }, "scripts": { "description": "Global scripts to be included in the build.", "type": "array", diff --git a/packages/angular-cli/models/webpack-build-common.ts b/packages/angular-cli/models/webpack-build-common.ts index 6c1e215edd8d..535e307485f6 100644 --- a/packages/angular-cli/models/webpack-build-common.ts +++ b/packages/angular-cli/models/webpack-build-common.ts @@ -1,10 +1,9 @@ import * as webpack from 'webpack'; import * as path from 'path'; 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, getOutputHashFormat } from './webpack-build-utils'; +import { extraEntryParser, lazyChunksFilter, getOutputHashFormat } from './webpack-build-utils'; const autoprefixer = require('autoprefixer'); const ProgressPlugin = require('webpack/lib/ProgressPlugin'); @@ -33,8 +32,7 @@ export function getWebpackCommonConfig( vendorChunk: boolean, verbose: boolean, progress: boolean, - outputHashing: string, - extractCss: boolean, + outputHashing: string ) { const appRoot = path.resolve(projectRoot, appConfig.root); @@ -42,10 +40,14 @@ export function getWebpackCommonConfig( let extraPlugins: any[] = []; let extraRules: any[] = []; - let lazyChunks: string[] = []; - let entryPoints: { [key: string]: string[] } = {}; + // figure out which are the lazy loaded entry points + const lazyChunks = lazyChunksFilter([ + ...extraEntryParser(appConfig.scripts, appRoot, 'scripts'), + ...extraEntryParser(appConfig.styles, appRoot, 'styles') + ]); + if (appConfig.main) { entryPoints['main'] = [path.resolve(appRoot, appConfig.main)]; } @@ -54,14 +56,15 @@ export function getWebpackCommonConfig( const hashFormat = getOutputHashFormat(outputHashing); // process global scripts - if (appConfig.scripts && appConfig.scripts.length > 0) { + if (appConfig.scripts.length > 0) { const globalScripts = extraEntryParser(appConfig.scripts, appRoot, 'scripts'); - // add entry points and lazy chunks - globalScripts.forEach(script => { - if (script.lazy) { lazyChunks.push(script.entry); } - entryPoints[script.entry] = (entryPoints[script.entry] || []).concat(script.path); - }); + // add script entry points + globalScripts.forEach(script => + entryPoints[script.entry] + ? entryPoints[script.entry].push(script.path) + : entryPoints[script.entry] = [script.path] + ); // load global scripts using script-loader extraRules.push({ @@ -69,36 +72,6 @@ export function getWebpackCommonConfig( }); } - // process global styles - if (!appConfig.styles || 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 (extractCss && 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({ name: 'vendor', @@ -136,6 +109,11 @@ export function getWebpackCommonConfig( if (progress) { extraPlugins.push(new ProgressPlugin({ profile: verbose, colors: true })); } + + const includePaths = [ + path.resolve(appRoot, './style-paths/') + ]; + return { devtool: sourcemap ? 'source-map' : false, performance: { hints: false }, @@ -158,26 +136,16 @@ export function getWebpackCommonConfig( module: { rules: [ { enforce: 'pre', test: /\.js$/, loader: 'source-map-loader', exclude: [nodeModules] }, - { test: /\.json$/, loader: 'json-loader' }, - { - test: /\.(jpg|png|gif)$/, - loader: `url-loader?name=[name]${hashFormat.file}.[ext]&limit=10000` - }, { test: /\.html$/, loader: 'raw-loader' }, - + { test: /\.(eot|svg)$/, loader: `file-loader?name=[name]${hashFormat.file}.[ext]` }, { - test: /\.(otf|ttf|woff|woff2)$/, + test: /\.(jpg|png|gif|otf|ttf|woff|woff2)$/, loader: `url-loader?name=[name]${hashFormat.file}.[ext]&limit=10000` - }, - { test: /\.(eot|svg)$/, loader: `file-loader?name=[name]${hashFormat.file}.[ext]` } + } ].concat(extraRules) }, plugins: [ - new ExtractTextPlugin({ - filename: `[name]${hashFormat.extract}.bundle.css`, - disable: !extractCss - }), new HtmlWebpackPlugin({ template: path.resolve(appRoot, appConfig.index), filename: path.resolve(appConfig.outDir, appConfig.index), @@ -191,18 +159,6 @@ export function getWebpackCommonConfig( new webpack.optimize.CommonsChunkPlugin({ minChunks: Infinity, name: 'inline' - }), - new webpack.LoaderOptionsPlugin({ - test: /\.(css|scss|sass|less|styl)$/, - options: { - 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), node: { diff --git a/packages/angular-cli/models/webpack-build-production.ts b/packages/angular-cli/models/webpack-build-production.ts index ffb198cf9d41..a6d32b7e65b0 100644 --- a/packages/angular-cli/models/webpack-build-production.ts +++ b/packages/angular-cli/models/webpack-build-production.ts @@ -1,8 +1,7 @@ import * as path from 'path'; import * as webpack from 'webpack'; import {CompressionPlugin} from '../lib/webpack/compression-plugin'; -const autoprefixer = require('autoprefixer'); -const postcssDiscardComments = require('postcss-discard-comments'); + declare module 'webpack' { export interface LoaderOptionsPlugin {} @@ -36,22 +35,6 @@ export const getWebpackProdConfigPartial = function(projectRoot: string, algorithm: 'gzip', 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: [ - 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-styles.ts b/packages/angular-cli/models/webpack-build-styles.ts new file mode 100644 index 000000000000..091eb7177537 --- /dev/null +++ b/packages/angular-cli/models/webpack-build-styles.ts @@ -0,0 +1,130 @@ +import * as webpack from 'webpack'; +import * as path from 'path'; +import { + SuppressExtractedTextChunksWebpackPlugin +} from '../plugins/suppress-entry-chunks-webpack-plugin'; +import { extraEntryParser, getOutputHashFormat } from './webpack-build-utils'; + +const postcssDiscardComments = require('postcss-discard-comments'); +const autoprefixer = require('autoprefixer'); +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') + * require('stylus-loader') + * require('less') + * require('less-loader') + * require('node-sass') + * require('sass-loader') + */ + +export function getWebpackStylesConfig( + projectRoot: string, + appConfig: any, + target: string, + sourcemap: boolean, + outputHashing: string, + extractCss: boolean, +) { + + const appRoot = path.resolve(projectRoot, appConfig.root); + const entryPoints: { [key: string]: string[] } = {}; + const globalStylePaths: string[] = []; + const extraPlugins: any[] = []; + + // discard comments in production + const extraPostCssPlugins = target === 'production' ? [postcssDiscardComments] : []; + + // determine hashing format + const hashFormat = getOutputHashFormat(outputHashing); + + // use includePaths from appConfig + const includePaths: string [] = []; + + if (appConfig.stylePreprocessorOptions + && appConfig.stylePreprocessorOptions.includePaths + && appConfig.stylePreprocessorOptions.includePaths.length > 0 + ) { + appConfig.stylePreprocessorOptions.includePaths.forEach((includePath: string) => + includePaths.push(path.resolve(appRoot, includePath))); + } + + // process global styles + if (appConfig.styles.length > 0) { + const globalStyles = extraEntryParser(appConfig.styles, appRoot, 'styles'); + // add style entry points + globalStyles.forEach(style => + entryPoints[style.entry] + ? entryPoints[style.entry].push(style.path) + : entryPoints[style.entry] = [style.path] + ); + // add global css paths + globalStylePaths.push(...globalStyles.map((style) => style.path)); + } + + // set base rules to derive final rules from + const baseRules = [ + { test: /\.css$/, loaders: [] }, + { test: /\.scss$|\.sass$/, loaders: ['sass-loader'] }, + { test: /\.less$/, loaders: ['less-loader'] }, + // stylus-loader doesn't support webpack.LoaderOptionsPlugin properly, + // so we need to add options in it's query + { test: /\.styl$/, loaders: [`stylus-loader?${JSON.stringify({ + sourceMap: sourcemap, + paths: includePaths + })}`] } + ]; + + const commonLoaders = ['postcss-loader']; + + // load component css as raw strings + let rules: any = baseRules.map(({test, loaders}) => ({ + exclude: globalStylePaths, test, loaders: ['raw-loader', ...commonLoaders, ...loaders] + })); + + // load global css as css files + if (globalStylePaths.length > 0) { + rules.push(...baseRules.map(({test, loaders}) => ({ + include: globalStylePaths, test, loaders: ExtractTextPlugin.extract({ + remove: false, + loader: ['css-loader', ...commonLoaders, ...loaders], + fallbackLoader: 'style-loader' + }) + }))); + } + + // supress empty .js files in css only entry points + if (extractCss) { extraPlugins.push(new SuppressExtractedTextChunksWebpackPlugin()); } + + return { + entry: entryPoints, + module: { rules }, + plugins: [ + // extract global css from js files into own css file + new ExtractTextPlugin({ + filename: `[name]${hashFormat.extract}.bundle.css`, + disable: !extractCss + }), + new webpack.LoaderOptionsPlugin({ + test: /\.(css|scss|sass|less|styl)$/, + options: { + postcss: [autoprefixer()].concat(extraPostCssPlugins), + cssLoader: { sourceMap: sourcemap }, + sassLoader: { sourceMap: sourcemap, includePaths }, + // less-loader doesn't support paths + lessLoader: { sourceMap: sourcemap }, + // stylus-loader doesn't support LoaderOptionsPlugin properly, options in query instead + // 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-utils.ts b/packages/angular-cli/models/webpack-build-utils.ts index 8ecd7787c17f..6a920b9e9122 100644 --- a/packages/angular-cli/models/webpack-build-utils.ts +++ b/packages/angular-cli/models/webpack-build-utils.ts @@ -1,22 +1,4 @@ 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); @@ -58,34 +40,11 @@ export interface ExtraEntry { 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 = ['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({ - remove: false, - loader: ['css-loader', ...commonLoaders, ...loaders], - fallbackLoader: 'style-loader' - }) - }))); - } - - return cssLoaders; +// +export function lazyChunksFilter(extraEntries: ExtraEntry[]) { + return extraEntries + .filter(extraEntry => extraEntry.lazy) + .map(extraEntry => extraEntry.entry); } // convert all extra entries into the object representation, fill in defaults diff --git a/packages/angular-cli/models/webpack-config.ts b/packages/angular-cli/models/webpack-config.ts index 1c286c61126a..893979274986 100644 --- a/packages/angular-cli/models/webpack-config.ts +++ b/packages/angular-cli/models/webpack-config.ts @@ -7,6 +7,7 @@ import { CliConfig } from './config'; import { getWebpackCommonConfig } from './webpack-build-common'; import { getWebpackDevConfigPartial } from './webpack-build-development'; import { getWebpackProdConfigPartial } from './webpack-build-production'; +import { getWebpackStylesConfig } from './webpack-build-styles'; import { getWebpackMobileConfigPartial, getWebpackMobileProdConfigPartial @@ -39,6 +40,8 @@ export class NgCliWebpackConfig { const appConfig = CliConfig.fromProject().config.apps[0]; const projectRoot = this.ngCliProject.root; + appConfig.scripts = appConfig.scripts || []; + appConfig.styles = appConfig.styles || []; appConfig.outDir = outputDir || appConfig.outDir; appConfig.deployUrl = deployUrl || appConfig.deployUrl; @@ -52,7 +55,6 @@ export class NgCliWebpackConfig { verbose, progress, outputHashing, - extractCss, ); let targetConfigPartial = this.getTargetConfig(projectRoot, appConfig, sourcemap, verbose); @@ -75,6 +77,17 @@ export class NgCliWebpackConfig { config = webpackMerge(config, typescriptConfigPartial); } + const stylesConfig = getWebpackStylesConfig( + projectRoot, + appConfig, + target, + sourcemap, + outputHashing, + extractCss + ); + + config = webpackMerge(config, stylesConfig); + this.config = config; } diff --git a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts index 0d62fc4f3ee3..5136c74d0342 100644 --- a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts +++ b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts @@ -1,20 +1,25 @@ -// ExtractTextPlugin leaves behind the entry points, which we might not need anymore -// if they were entirely css. This plugin removes those entry points. +// Remove .js files from entry points consisting entirely of .css|scss|sass|less|styl. +// To be used together with ExtractTextPlugin. -export interface SuppressEntryChunksWebpackPluginOptions { - chunks: string[]; -} - -export class SuppressEntryChunksWebpackPlugin { - constructor(private options: SuppressEntryChunksWebpackPluginOptions) { } +export class SuppressExtractedTextChunksWebpackPlugin { + constructor() { } apply(compiler: any): void { - let { chunks } = this.options; compiler.plugin('compilation', function (compilation: any) { + // find which chunks have css only entry points + const cssOnlyChunks: string[] = []; + const entryPoints = compilation.options.entry; + // determine which entry points are composed entirely of css files + for (let entryPoint of Object.keys(entryPoints)) { + if (entryPoints[entryPoint].every((el: string) => + el.match(/\.(css|scss|sass|less|styl)$/))) { + cssOnlyChunks.push(entryPoint); + } + } // Remove the js file for supressed chunks compilation.plugin('after-seal', (callback: any) => { compilation.chunks - .filter((chunk: any) => chunks.indexOf(chunk.name) !== -1) + .filter((chunk: any) => cssOnlyChunks.indexOf(chunk.name) !== -1) .forEach((chunk: any) => { let newFiles: string[] = []; chunk.files.forEach((file: string) => { diff --git a/tests/e2e/tests/build/styles/include-paths.ts b/tests/e2e/tests/build/styles/include-paths.ts new file mode 100644 index 000000000000..3dcd8796be3a --- /dev/null +++ b/tests/e2e/tests/build/styles/include-paths.ts @@ -0,0 +1,60 @@ +import { + writeMultipleFiles, + expectFileToMatch, + replaceInFile, + createDir +} from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default function () { + return Promise.resolve() + .then(() => createDir('src/style-paths')) + .then(() => writeMultipleFiles({ + 'src/style-paths/_variables.scss': '$primary-color: red;', + 'src/styles.scss': ` + @import 'variables'; + h1 { color: $primary-color; } + `, + 'src/app/app.component.scss': ` + @import 'variables'; + h2 { background-color: $primary-color; } + `, + 'src/style-paths/variables.styl': '$primary-color = green', + 'src/styles.styl': ` + @import 'variables' + h3 + color: $primary-color + `, + 'src/app/app.component.styl': ` + @import 'variables' + h4 + background-color: $primary-color + ` + })) + .then(() => replaceInFile('src/app/app.component.ts', `'./app.component.css\'`, + `'./app.component.scss', './app.component.styl'`)) + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['styles'] = [ + 'styles.scss', + 'styles.styl' + ]; + app['stylePreprocessorOptions'] = { + includePaths: [ + 'style-paths' + ] + }; + })) + // files were created successfully + .then(() => ng('build')) + .then(() => expectFileToMatch('dist/styles.bundle.css', /h1\s*{\s*color: red;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /h2\s*{\s*color: red;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', /h3\s*{\s*color: 008000;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /h4\s*{\s*color: 008000;\s*}/)) + .then(() => ng('build', '--prod')) + .then(() => expectFileToMatch('dist/styles.bundle.css', /h1\s*{\s*color: red;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /h2\s*{\s*color: red;\s*}/)) + .then(() => expectFileToMatch('dist/styles.bundle.css', /h3\s*{\s*color: 008000;\s*}/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /h4\s*{\s*color: 008000;\s*}/)); +}