From 6754390f7c4dc44563f3111503c3fcf7fa2128a1 Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Tue, 14 Feb 2017 14:14:03 +0000 Subject: [PATCH] feat(@angular/cli): allow assets from outside of app root. Fix #3555 BREAKING CHANGE: 'assets' as a string in angular-cli.json is no longer allowed, use an array instead. --- .../stories/asset-configuration.md | 34 +++++- packages/@angular/cli/lib/config/schema.json | 33 +++-- .../cli/plugins/glob-copy-webpack-plugin.ts | 97 ++++++++++----- packages/@angular/cli/plugins/karma.js | 47 ++++++-- packages/@angular/cli/tasks/serve.ts | 1 - tests/e2e/tests/build/assets.ts | 113 ++++++++++++++++++ tests/e2e/tests/misc/assets.ts | 22 ---- tests/e2e/tests/test/test-assets.ts | 49 -------- 8 files changed, 276 insertions(+), 120 deletions(-) create mode 100644 tests/e2e/tests/build/assets.ts delete mode 100644 tests/e2e/tests/misc/assets.ts delete mode 100644 tests/e2e/tests/test/test-assets.ts diff --git a/docs/documentation/stories/asset-configuration.md b/docs/documentation/stories/asset-configuration.md index 1c7d319d4090..9809f28f0667 100644 --- a/docs/documentation/stories/asset-configuration.md +++ b/docs/documentation/stories/asset-configuration.md @@ -1,9 +1,39 @@ # Project assets -You use the `assets` array in `angular-cli.json` to list files or folders you want to copy as-is when building your project: +You use the `assets` array in `angular-cli.json` to list files or folders you want to copy as-is +when building your project. + +By default, the `src/assets/` folder and `src/favicon.ico` are copied over. + ```json "assets": [ "assets", "favicon.ico" ] -``` \ No newline at end of file +``` + +You can also further configure assets to be copied by using objects as configuration. + +The array below does the same as the default one: + +```json +"assets": [ + { "glob": "**/*", "input": "./assets/", "output": "./assets/" }, + { "glob": "favicon.ico", "input": "./", "output": "./" }, +] +``` + +`glob` is the a [node-glob](https://github.com/isaacs/node-glob) using `input` as base directory. +`input` is relative to the project root (`src/` default), while `output` is + relative to `outDir` (`dist` default). + + You can use this extended configuration to copy assets from outside your project. + For instance, you can copy assets from a node package: + + ```json +"assets": [ + { "glob": "**/*", "input": "../node_modules/some-package/images", "output": "./some-package/" }, +] +``` + +The contents of `node_modules/some-package/images/` will be available in `dist/some-package/`. \ No newline at end of file diff --git a/packages/@angular/cli/lib/config/schema.json b/packages/@angular/cli/lib/config/schema.json index d586f9820361..23ee4ec620a7 100644 --- a/packages/@angular/cli/lib/config/schema.json +++ b/packages/@angular/cli/lib/config/schema.json @@ -31,17 +31,32 @@ "default": "dist/" }, "assets": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" + }, + { + "type": "object", + "properties": { + "glob": { + "type": "string", + "default": "" + }, + "input": { + "type": "string", + "default": "" + }, + "output": { + "type": "string", + "default": "" + } + }, + "additionalProperties": false } - } - ], + ] + }, "default": [] }, "deployUrl": { diff --git a/packages/@angular/cli/plugins/glob-copy-webpack-plugin.ts b/packages/@angular/cli/plugins/glob-copy-webpack-plugin.ts index 5d288140a514..248ad293ba4c 100644 --- a/packages/@angular/cli/plugins/glob-copy-webpack-plugin.ts +++ b/packages/@angular/cli/plugins/glob-copy-webpack-plugin.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as glob from 'glob'; import * as denodeify from 'denodeify'; +const flattenDeep = require('lodash/flattenDeep'); const globPromise = denodeify(glob); const statPromise = denodeify(fs.stat); @@ -14,48 +15,90 @@ function isDirectory(path: string) { } } +interface Asset { + originPath: string; + destinationPath: string; + relativePath: string; +} + +export interface Pattern { + glob: string; + input?: string; + output?: string; +} + export interface GlobCopyWebpackPluginOptions { - patterns: string[]; + patterns: (string | Pattern)[]; globOptions: any; } +// Adds an asset to the compilation assets; +function addAsset(compilation: any, asset: Asset) { + const realPath = path.resolve(asset.originPath, asset.relativePath); + // Make sure that asset keys use forward slashes, otherwise webpack dev server + const servedPath = path.join(asset.destinationPath, asset.relativePath).replace(/\\/g, '/'); + + // Don't re-add existing assets. + if (compilation.assets[servedPath]) { + return Promise.resolve(); + } + + // Read file and add it to assets; + return statPromise(realPath) + .then((stat: any) => compilation.assets[servedPath] = { + size: () => stat.size, + source: () => fs.readFileSync(realPath) + }); +} + export class GlobCopyWebpackPlugin { constructor(private options: GlobCopyWebpackPluginOptions) { } apply(compiler: any): void { let { patterns, globOptions } = this.options; - let context = globOptions.cwd || compiler.options.context; - let optional = !!globOptions.optional; + const defaultCwd = globOptions.cwd || compiler.options.context; - // convert dir patterns to globs - patterns = patterns.map(pattern => isDirectory(path.resolve(context, pattern)) - ? pattern += '/**/*' - : pattern - ); - - // force nodir option, since we can't add dirs to assets + // Force nodir option, since we can't add dirs to assets. globOptions.nodir = true; + // Process patterns. + patterns = patterns.map(pattern => { + // Convert all string patterns to Pattern type. + pattern = typeof pattern === 'string' ? { glob: pattern } : pattern; + // Add defaults + // Input is always resolved relative to the defaultCwd (appRoot) + pattern.input = path.resolve(defaultCwd, pattern.input || ''); + pattern.output = pattern.output || ''; + pattern.glob = pattern.glob || ''; + // Convert dir patterns to globs. + if (isDirectory(path.resolve(pattern.input, pattern.glob))) { + pattern.glob = pattern.glob + '/**/*'; + } + return pattern; + }); + compiler.plugin('emit', (compilation: any, cb: any) => { - let globs = patterns.map(pattern => globPromise(pattern, globOptions)); - - let addAsset = (relPath: string) => compilation.assets[relPath] - // don't re-add to assets - ? Promise.resolve() - : statPromise(path.resolve(context, relPath)) - .then((stat: any) => compilation.assets[relPath] = { - size: () => stat.size, - source: () => fs.readFileSync(path.resolve(context, relPath)) - }) - .catch((err: any) => optional ? Promise.resolve() : Promise.reject(err)); + // Create an array of promises for each pattern glob + const globs = patterns.map((pattern: Pattern) => new Promise((resolve, reject) => + // Individual patterns can override cwd + globPromise(pattern.glob, Object.assign({}, globOptions, { cwd: pattern.input })) + // Map the results onto an Asset + .then((globResults: string[]) => globResults.map(res => ({ + originPath: pattern.input, + destinationPath: pattern.output, + relativePath: res + }))) + .then((asset: Asset) => resolve(asset)) + .catch(reject) + )); + // Wait for all globs. Promise.all(globs) - // flatten results - .then(globResults => [].concat.apply([], globResults)) - // add each file to compilation assets - .then((relPaths: string[]) => - Promise.all(relPaths.map((relPath: string) => addAsset(relPath)))) - .catch((err) => compilation.errors.push(err)) + // Flatten results. + .then(assets => flattenDeep(assets)) + // Add each asset to the compilation. + .then(assets => + Promise.all(assets.map((asset: Asset) => addAsset(compilation, asset)))) .then(() => cb()); }); } diff --git a/packages/@angular/cli/plugins/karma.js b/packages/@angular/cli/plugins/karma.js index f8f2f5b7e147..1628d7eada04 100644 --- a/packages/@angular/cli/plugins/karma.js +++ b/packages/@angular/cli/plugins/karma.js @@ -4,6 +4,14 @@ const fs = require('fs'); const getTestConfig = require('../models/webpack-configs/test').getTestConfig; const CliConfig = require('../models/config').CliConfig; +function isDirectory(path) { + try { + return fs.statSync(path).isDirectory(); + } catch (_) { + return false; + } +} + const init = (config) => { // load Angular CLI config if (!config.angularCli || !config.angularCli.config) { @@ -19,24 +27,43 @@ const init = (config) => { progress: config.angularCli.progress } - // add assets + // Add assets. This logic is mimics the one present in GlobCopyWebpackPlugin. if (appConfig.assets) { - const assets = typeof appConfig.assets === 'string' ? [appConfig.assets] : appConfig.assets; config.proxies = config.proxies || {}; - assets.forEach(asset => { - const fullAssetPath = path.join(config.basePath, appConfig.root, asset); - const isDirectory = fs.lstatSync(fullAssetPath).isDirectory(); - const filePattern = isDirectory ? fullAssetPath + '/**' : fullAssetPath; - const proxyPath = isDirectory ? asset + '/' : asset; + appConfig.assets.forEach(pattern => { + // Convert all string patterns to Pattern type. + pattern = typeof pattern === 'string' ? { glob: pattern } : pattern; + // Add defaults. + // Input is always resolved relative to the appRoot. + pattern.input = path.resolve(appRoot, pattern.input || ''); + pattern.output = pattern.output || ''; + pattern.glob = pattern.glob || ''; + + // Build karma file pattern. + const assetPath = path.join(pattern.input, pattern.glob); + const filePattern = isDirectory(assetPath) ? assetPath + '/**' : assetPath; config.files.push({ pattern: filePattern, included: false, served: true, watched: true }); - // The `files` entry serves the file from `/base/{appConfig.root}/{asset}` - // so, we need to add a URL rewrite that exposes the asset as `/{asset}` only - config.proxies['/' + proxyPath] = '/base/' + appConfig.root + '/' + proxyPath; + + // The `files` entry serves the file from `/base/{asset.input}/{asset.glob}`. + // We need to add a URL rewrite that exposes the asset as `/{asset.output}/{asset.glob}`. + let relativePath, proxyPath; + if (fs.existsSync(assetPath)) { + relativePath = path.relative(config.basePath, assetPath); + proxyPath = path.join(pattern.output, pattern.glob); + } else { + // For globs (paths that don't exist), proxy pattern.output to pattern.input. + relativePath = path.relative(config.basePath, pattern.input); + proxyPath = path.join(pattern.output); + + } + // Proxy paths must have only forward slashes. + proxyPath = proxyPath.replace(/\\/g, '/'); + config.proxies['/' + proxyPath] = '/base/' + relativePath; }); } diff --git a/packages/@angular/cli/tasks/serve.ts b/packages/@angular/cli/tasks/serve.ts index 287d237596cb..502b1c389d20 100644 --- a/packages/@angular/cli/tasks/serve.ts +++ b/packages/@angular/cli/tasks/serve.ts @@ -98,7 +98,6 @@ export default Task.extend({ } const webpackDevServerConfiguration: IWebpackDevServerConfigurationOptions = { - contentBase: path.join(this.project.root, `./${appConfig.root}`), headers: { 'Access-Control-Allow-Origin': '*' }, historyApiFallback: { index: `/${appConfig.index}`, diff --git a/tests/e2e/tests/build/assets.ts b/tests/e2e/tests/build/assets.ts new file mode 100644 index 000000000000..fba6bde9fcc4 --- /dev/null +++ b/tests/e2e/tests/build/assets.ts @@ -0,0 +1,113 @@ +import { + writeMultipleFiles, + createDir, + expectFileToMatch, + expectFileToExist +} from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + + +export default function () { + return Promise.resolve() + .then(_ => createDir('./src/folder')) + .then(_ => createDir('./node_modules/some-package/')) + // Write assets. + .then(_ => writeMultipleFiles({ + './src/folder/.gitkeep': '', + './src/folder/folder-asset.txt': 'folder-asset.txt', + './src/string-asset.txt': 'string-asset.txt', + './src/glob-asset.txt': 'glob-asset.txt', + './src/output-asset.txt': 'output-asset.txt', + './node_modules/some-package/node_modules-asset.txt': 'node_modules-asset.txt', + })) + // Add asset config in angular-cli.json. + .then(() => updateJsonFile('angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['assets'] = [ + 'folder', + 'string-asset.txt', + { 'glob': 'glob-asset.txt' }, + { 'glob': 'output-asset.txt', 'output': 'output-folder' }, + { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': 'package-folder' } + ]; + })) + // Test files are present on build output. + .then(() => ng('build')) + .then(() => expectFileToMatch('./dist/folder/folder-asset.txt', 'folder-asset.txt')) + .then(() => expectFileToMatch('./dist/string-asset.txt', 'string-asset.txt')) + .then(() => expectFileToMatch('./dist/glob-asset.txt', 'glob-asset.txt')) + .then(() => expectFileToMatch('./dist/output-folder/output-asset.txt', 'output-asset.txt')) + .then(() => expectFileToMatch('./dist/package-folder/node_modules-asset.txt', + 'node_modules-asset.txt')) + // .gitkeep shouldn't be copied. + .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) + // Update app to test assets are present. + .then(_ => writeMultipleFiles({ + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { Http, Response } from '@angular/http'; + import 'rxjs/add/operator/map'; + + @Component({ + selector: 'app-root', + template: '

{{asset.content }}

' + }) + export class AppComponent { + public assets = [ + { path: './folder/folder-asset.txt', content: '' }, + { path: './string-asset.txt', content: '' }, + { path: './glob-asset.txt', content: '' }, + { path: './output-folder/output-asset.txt', content: '' }, + { path: './package-folder/node_modules-asset.txt', content: '' }, + ]; + constructor(private http: Http) { + this.assets.forEach(asset => http.get(asset.path) + .subscribe(res => asset.content = res['_body'])); + } + }`, + 'src/app/app.component.spec.ts': ` + import { TestBed, async } from '@angular/core/testing'; + import { HttpModule } from '@angular/http'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpModule + ], + declarations: [ + AppComponent + ], + }); + TestBed.compileComponents(); + }); + + it('should create the app', async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + })); + });`, + 'e2e/app.e2e-spec.ts': ` + import { browser, element, by } from 'protractor'; + + describe('master-project App', function () { + it('should display asset contents', () => { + browser.get('/'); + element.all(by.css('app-root p')).then(function (assets) { + expect(assets.length).toBe(5); + expect(assets[0].getText()).toBe('folder-asset.txt'); + expect(assets[1].getText()).toBe('string-asset.txt'); + expect(assets[2].getText()).toBe('glob-asset.txt'); + expect(assets[3].getText()).toBe('output-asset.txt'); + expect(assets[4].getText()).toBe('node_modules-asset.txt'); + }); + }); + });`, + })) + .then(() => ng('test', '--single-run')) + .then(() => ng('e2e', '--no-progress')); +} diff --git a/tests/e2e/tests/misc/assets.ts b/tests/e2e/tests/misc/assets.ts deleted file mode 100644 index 9dd9504cfac2..000000000000 --- a/tests/e2e/tests/misc/assets.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {writeFile, expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return writeFile('src/assets/.file', '') - .then(() => writeFile('src/assets/test.abc', 'hello world')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/favicon.ico')) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')) - .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) - // doesn't break beta.16 projects - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = 'assets'; - })) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')); -} diff --git a/tests/e2e/tests/test/test-assets.ts b/tests/e2e/tests/test/test-assets.ts deleted file mode 100644 index 9394cfe47a8b..000000000000 --- a/tests/e2e/tests/test/test-assets.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import { stripIndent } from 'common-tags'; - -// Make sure asset files are served -export default function () { - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/assets/file.txt': 'assets-folder-content', - 'src/file.txt': 'file-content', - // Not using `async()` in tests as it seemed to swallow `fetch()` errors - 'src/app/app.component.spec.ts': stripIndent` - describe('Test Runner', () => { - const fetch = global['fetch']; - it('should serve files in assets folder', (done) => { - fetch('/assets/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('assets-folder-content'); - done(); - }); - }); - it('should serve files explicitly added to assets array', (done) => { - fetch('/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('file-content'); - done(); - }); - }); - }); - ` - })) - // Test failure condition (no assets in angular-cli.json) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = []; - })) - .then(() => expectToFail(() => ng('test', '--single-run'), - 'Should fail because the assets to serve were not in the Angular CLI config')) - // Test passing condition (assets are included) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = ['assets', 'file.txt']; - })) - .then(() => ng('test', '--single-run')); -}