diff --git a/README.md b/README.md index 8b8711cc1a01..749147bfad21 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The generated project has dependencies that require **Node 4 or greater**. * [Deploying the App via GitHub Pages](#deploying-the-app-via-github-pages) * [Support for offline applications](#support-for-offline-applications) * [Commands autocompletion](#commands-autocompletion) +* [CSS preprocessor integration](#css-preprocessor-integration) * [Known Issues](#known-issues) ## Installation @@ -249,6 +250,18 @@ ng completion >> ~/.bash_profile source ~/.bash_profile ``` + +### CSS Preprocessor integration + +We support all major CSS preprocessors: +- sass (node-sass) +- less (less) +- compass (compass-importer + node-sass) +- stylus (stylus) + +To use one just install for example `npm install node-sass` and rename `.css` files in your project to `.scss` or `.sass`. They will be compiled automatically. + + ## Known issues This project is currently a prototype so there are many known issues. Just to mention a few: diff --git a/lib/broccoli/angular-broccoli-compass.js b/lib/broccoli/angular-broccoli-compass.js new file mode 100644 index 000000000000..5035cf099693 --- /dev/null +++ b/lib/broccoli/angular-broccoli-compass.js @@ -0,0 +1,59 @@ +/* jshint node: true, esversion: 6 */ +'use strict'; + +const requireOrNull = require('./require-or-null'); +const Plugin = require('broccoli-caching-writer'); +const fse = require('fs-extra'); +const path = require('path'); +const Funnel = require('broccoli-funnel'); + +let sass = requireOrNull('node-sass'); +let compass = requireOrNull('compass'); +if (!sass || !compass) { + sass = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/node-sass`); + compass = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/compass-importer`); +} + +class CompassPlugin extends Plugin { + constructor(inputNodes, options) { + super(inputNodes, {}); + + options = options || {}; + Plugin.call(this, inputNodes, { + cacheInclude: [/\.scss$/, /\.sass$/] + }); + this.options = options; + } + + build() { + this.listEntries().forEach(e => { + let fileName = path.resolve(e.basePath, e.relativePath); + this.compile(fileName, this.inputPaths[0], this.outputPath); + }); + } + + compile(fileName, inputPath, outputPath) { + let sassOptions = { + file: path.normalize(fileName), + includePaths: this.inputPaths, + data: '@import "compass"; .transition { @include transition(all); }', + importer: compass + }; + + let result = sass.renderSync(sassOptions); + let filePath = fileName.replace(inputPath, outputPath).replace(/\.s[ac]ss$/, '.css'); + + fse.outputFileSync(filePath, result.css, 'utf8'); + } +} + +exports.makeBroccoliTree = (sourceDir) => { + if (sass && compass) { + let compassSrcTree = new Funnel(sourceDir, { + include: ['**/*.scss', '**/*.sass'], + allowEmpty: true + }); + + return new CompassPlugin([compassSrcTree]); + } +}; diff --git a/lib/broccoli/angular-broccoli-less.js b/lib/broccoli/angular-broccoli-less.js new file mode 100644 index 000000000000..077b7358c91b --- /dev/null +++ b/lib/broccoli/angular-broccoli-less.js @@ -0,0 +1,54 @@ +/* jshint node: true, esversion: 6 */ +'use strict'; + +const requireOrNull = require('./require-or-null'); +const Plugin = require('broccoli-caching-writer'); +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); +const Funnel = require('broccoli-funnel'); + +let less = requireOrNull('less'); +if (!less) { + less = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/less`); +} + +class LESSPlugin extends Plugin { + constructor(inputNodes, options) { + super(inputNodes, {}); + + options = options || {}; + Plugin.call(this, inputNodes, { + cacheInclude: [/\.less$/] + }); + this.options = options; + } + + build() { + return Promise.all(this.listEntries().map(e => { + let fileName = path.resolve(e.basePath, e.relativePath); + return this.compile(fileName, this.inputPaths[0], this.outputPath); + })); + } + + compile(fileName, inputPath, outputPath) { + let content = fs.readFileSync(fileName, 'utf8'); + + return less.render(content) + .then(output => { + let filePath = fileName.replace(inputPath, outputPath).replace(/\.less$/, '.css'); + fse.outputFileSync(filePath, output.css, 'utf8'); + }); + } +} + +exports.makeBroccoliTree = (sourceDir) => { + if (less) { + let lessSrcTree = new Funnel(sourceDir, { + include: ['**/*.less'], + allowEmpty: true + }); + + return new LESSPlugin([lessSrcTree]); + } +}; diff --git a/lib/broccoli/angular-broccoli-sass.js b/lib/broccoli/angular-broccoli-sass.js new file mode 100644 index 000000000000..8fc3a196d61a --- /dev/null +++ b/lib/broccoli/angular-broccoli-sass.js @@ -0,0 +1,61 @@ +/* jshint node: true, esversion: 6 */ +'use strict'; + +const requireOrNull = require('./require-or-null'); +const Plugin = require('broccoli-caching-writer'); +const fse = require('fs-extra'); +const path = require('path'); +const Funnel = require('broccoli-funnel'); + +let sass = requireOrNull('node-sass'); +if (!sass) { + sass = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/node-sass`); +} + +class SASSPlugin extends Plugin { + constructor(inputNodes, options) { + super(inputNodes, {}); + + options = options || {}; + Plugin.call(this, inputNodes, { + cacheInclude: [/\.scss$/, /\.sass$/] + }); + this.options = options; + } + + build() { + this.listEntries().forEach(e => { + let fileName = path.resolve(e.basePath, e.relativePath); + this.compile(fileName, this.inputPaths[0], this.outputPath); + }); + } + + compile(fileName, inputPath, outputPath) { + let sassOptions = { + file: path.normalize(fileName), + includePaths: this.inputPaths + }; + + let result = sass.renderSync(sassOptions); + let filePath = fileName.replace(inputPath, outputPath).replace(/\.s[ac]ss$/, '.css'); + + fse.outputFileSync(filePath, result.css, 'utf8'); + } +} + +exports.makeBroccoliTree = (sourceDir) => { + // include sass support only if compass-importer is not installed + let compass = requireOrNull('compass-importer'); + if (!compass) { + compass = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/compass-importer`); + } + + if (sass && !compass) { + let sassSrcTree = new Funnel(sourceDir, { + include: ['**/*.sass', '**/*.scss'], + allowEmpty: true + }); + + return new SASSPlugin([sassSrcTree]); + } +}; diff --git a/lib/broccoli/angular-broccoli-stylus.js b/lib/broccoli/angular-broccoli-stylus.js new file mode 100644 index 000000000000..d7ee8e99bc99 --- /dev/null +++ b/lib/broccoli/angular-broccoli-stylus.js @@ -0,0 +1,53 @@ +/* jshint node: true, esversion: 6 */ +'use strict'; + +const requireOrNull = require('./require-or-null'); +const Plugin = require('broccoli-caching-writer'); +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); +const Funnel = require('broccoli-funnel'); + +let stylus = requireOrNull('stylus'); +if (!stylus) { + stylus = requireOrNull(`${process.env.PROJECT_ROOT}/node_modules/stylus`); +} + +class StylusPlugin extends Plugin { + constructor(inputNodes, options) { + super(inputNodes, {}); + + options = options || {}; + Plugin.call(this, inputNodes, { + cacheInclude: [/\.styl$/] + }); + this.options = options; + } + + build() { + return Promise.all(this.listEntries().map(e => { + let fileName = path.resolve(e.basePath, e.relativePath); + return this.compile(fileName, this.inputPaths[0], this.outputPath); + })); + } + + compile(fileName, inputPath, outputPath) { + let content = fs.readFileSync(fileName, 'utf8'); + + return stylus.render(content, { filename: path.basename(fileName) }, function(err, css) { + let filePath = fileName.replace(inputPath, outputPath).replace(/\.styl$/, '.css'); + fse.outputFileSync(filePath, css, 'utf8'); + }); + } +} + +exports.makeBroccoliTree = (sourceDir) => { + if (stylus) { + let stylusSrcTree = new Funnel(sourceDir, { + include: ['**/*.styl'], + allowEmpty: true + }); + + return new StylusPlugin([stylusSrcTree]); + } +}; diff --git a/lib/broccoli/angular2-app.js b/lib/broccoli/angular2-app.js index 18022b229724..e0a0c36f0fc5 100644 --- a/lib/broccoli/angular2-app.js +++ b/lib/broccoli/angular2-app.js @@ -50,6 +50,13 @@ Angular2App.prototype.toTree = function () { buildTrees.push(excludeDotfilesTree); } + buildTrees = buildTrees.concat( + require('./angular-broccoli-sass').makeBroccoliTree(sourceDir), + require('./angular-broccoli-less').makeBroccoliTree(sourceDir), + require('./angular-broccoli-stylus').makeBroccoliTree(sourceDir), + require('./angular-broccoli-compass').makeBroccoliTree(sourceDir) + ).filter(x => !!x); + var merged = mergeTrees(buildTrees, { overwrite: true }); return mergeTrees([merged, new SwManifest([merged])]); @@ -63,6 +70,9 @@ Angular2App.prototype.toTree = function () { */ Angular2App.prototype._initProject = function () { this.project = Project.closestSync(process.cwd()); + + // project root dir env used on angular-cli side for including packages from project + process.env.PROJECT_ROOT = process.env.PROJECT_ROOT || this.project.root; }; /** @@ -304,7 +314,14 @@ Angular2App.prototype._getVendorNpmTree = function () { Angular2App.prototype._getAssetsTree = function () { return new Funnel(sourceDir, { include: ['**/*.*'], - exclude: ['**/*.ts', '**/*.js'], + exclude: [ + '**/*.ts', + '**/*.js', + '**/*.scss', + '**/*.sass', + '**/*.less', + '**/*.styl' + ], allowEmpty: true }); }; diff --git a/lib/broccoli/require-or-null.js b/lib/broccoli/require-or-null.js new file mode 100644 index 000000000000..9866bf69c91f --- /dev/null +++ b/lib/broccoli/require-or-null.js @@ -0,0 +1,10 @@ +/* jshint node: true, esversion: 6 */ +'use strict'; + +module.exports = function(name) { + try { + return require(name); + } catch (e) { + return null; + } +}; diff --git a/tests/e2e/e2e_workflow.spec.js b/tests/e2e/e2e_workflow.spec.js index 72fefc581ea8..5eb434ee8d56 100644 --- a/tests/e2e/e2e_workflow.spec.js +++ b/tests/e2e/e2e_workflow.spec.js @@ -43,7 +43,7 @@ describe('Basic end-to-end Workflow', function () { }); it('Can create new project using `ng new test-project`', function () { - this.timeout(420000); + this.timeout(4200000); return ng(['new', 'test-project', '--silent']).then(function () { expect(existsSync(path.join(root, 'test-project'))); @@ -208,6 +208,110 @@ describe('Basic end-to-end Workflow', function () { }); }); + it('Installs sass support successfully', function() { + this.timeout(420000); + + sh.exec('npm install node-sass', { silent: true }); + return ng(['generate', 'component', 'test-component']) + .then(() => { + let componentPath = path.join(process.cwd(), 'src', 'client', 'app', 'test-component'); + let cssFile = path.join(componentPath, 'test-component.css'); + let scssFile = path.join(componentPath, 'test-component.scss'); + + expect(existsSync(componentPath)).to.be.equal(true); + sh.mv(cssFile, scssFile); + expect(existsSync(scssFile)).to.be.equal(true); + expect(existsSync(cssFile)).to.be.equal(false); + let scssExample = '.outer {\n .inner { background: #fff; }\n }'; + fs.writeFileSync(scssFile, scssExample, 'utf8'); + + sh.exec('ng build --silent'); + let destCss = path.join(process.cwd(), 'dist', 'app', 'test-component', 'test-component.css'); + expect(existsSync(destCss)).to.be.equal(true); + let contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + sh.rm('-f', destCss); + process.chdir('src'); + sh.exec('ng build --silent'); + expect(existsSync(destCss)).to.be.equal(true); + contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + process.chdir('..'); + sh.exec('npm uninstall node-sass', { silent: true }); + }); + }); + + it('Installs less support successfully', function() { + this.timeout(420000); + + sh.exec('npm install less', { silent: true }); + return ng(['generate', 'component', 'test-component']) + .then(() => { + let componentPath = path.join(process.cwd(), 'src', 'client', 'app', 'test-component'); + let cssFile = path.join(componentPath, 'test-component.css'); + let lessFile = path.join(componentPath, 'test-component.less'); + + expect(existsSync(componentPath)).to.be.equal(true); + sh.mv(cssFile, lessFile); + expect(existsSync(lessFile)).to.be.equal(true); + expect(existsSync(cssFile)).to.be.equal(false); + let lessExample = '.outer {\n .inner { background: #fff; }\n }'; + fs.writeFileSync(lessFile, lessExample, 'utf8'); + + sh.exec('ng build --silent'); + let destCss = path.join(process.cwd(), 'dist', 'app', 'test-component', 'test-component.css'); + expect(existsSync(destCss)).to.be.equal(true); + let contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + sh.rm('-f', destCss); + process.chdir('src'); + sh.exec('ng build --silent'); + expect(existsSync(destCss)).to.be.equal(true); + contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + process.chdir('..'); + sh.exec('npm uninstall less', { silent: true }); + }); + }); + + it('Installs stylus support successfully', function() { + this.timeout(420000); + + sh.exec('npm install stylus', { silent: true }); + return ng(['generate', 'component', 'test-component']) + .then(() => { + let componentPath = path.join(process.cwd(), 'src', 'client', 'app', 'test-component'); + let cssFile = path.join(componentPath, 'test-component.css'); + let stylusFile = path.join(componentPath, 'test-component.styl'); + + sh.mv(cssFile, stylusFile); + expect(existsSync(stylusFile)).to.be.equal(true); + expect(existsSync(cssFile)).to.be.equal(false); + let stylusExample = '.outer {\n .inner { background: #fff; }\n }'; + fs.writeFileSync(stylusFile, stylusExample, 'utf8'); + + sh.exec('ng build --silent'); + let destCss = path.join(process.cwd(), 'dist', 'app', 'test-component', 'test-component.css'); + expect(existsSync(destCss)).to.be.equal(true); + let contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + sh.rm('-f', destCss); + process.chdir('src'); + sh.exec('ng build --silent'); + expect(existsSync(destCss)).to.be.equal(true); + contents = fs.readFileSync(destCss, 'utf8'); + expect(contents).to.include('.outer .inner'); + + process.chdir('..'); + sh.exec('npm uninstall stylus', { silent: true }); + }); + }); + it('Turn on `noImplicitAny` in tsconfig.json and rebuild', function (done) { this.timeout(420000);