feat(bazel): ng-new schematics with Bazel (#27277)

This commit creates a schematics for ng new command that builds
the project with Bazel.

PR Close #27277
This commit is contained in:
Keen Yee Liau 2018-09-14 15:03:29 +01:00 committed by Alex Rickabaugh
parent 7ec05b4d8c
commit 06d4a0c46e
16 changed files with 633 additions and 9 deletions

View File

@ -7,6 +7,7 @@ npm_package(
"package.json", "package.json",
"protractor-utils.js", "protractor-utils.js",
"//packages/bazel/src:package_assets", "//packages/bazel/src:package_assets",
"//packages/bazel/src/builders:package_assets",
"//packages/bazel/src/schematics:package_assets", "//packages/bazel/src/schematics:package_assets",
], ],
packages = [ packages = [
@ -19,5 +20,6 @@ npm_package(
"//packages/bazel/src/ngc-wrapped:ngc_lib", "//packages/bazel/src/ngc-wrapped:ngc_lib",
"//packages/bazel/src/protractor/utils", "//packages/bazel/src/protractor/utils",
"//packages/bazel/src/schematics/bazel-workspace", "//packages/bazel/src/schematics/bazel-workspace",
"//packages/bazel/src/schematics/ng-new",
], ],
) )

View File

@ -1,7 +1,7 @@
{ {
"builders": { "builders": {
"build": { "build": {
"class": "./index#Builder", "class": "./index",
"schema": "./schema.json", "schema": "./schema.json",
"description": "Executes Bazel on a target." "description": "Executes Bazel on a target."
} }

View File

@ -5,10 +5,10 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
* *
* @fileoverview Bazel bundle builder * @fileoverview Bazel builder
*/ */
import {BuildEvent, Builder as BuilderInterface, BuilderConfiguration, BuilderContext} from '@angular-devkit/architect'; import {BuildEvent, Builder, BuilderConfiguration, BuilderContext} from '@angular-devkit/architect';
import {getSystemPath, resolve} from '@angular-devkit/core'; import {getSystemPath, resolve} from '@angular-devkit/core';
import {Observable, of } from 'rxjs'; import {Observable, of } from 'rxjs';
import {catchError, map, tap} from 'rxjs/operators'; import {catchError, map, tap} from 'rxjs/operators';
@ -16,7 +16,7 @@ import {catchError, map, tap} from 'rxjs/operators';
import {checkInstallation, runBazel} from './bazel'; import {checkInstallation, runBazel} from './bazel';
import {Schema} from './schema'; import {Schema} from './schema';
export class Builder implements BuilderInterface<Schema> { class BazelBuilder implements Builder<Schema> {
constructor(private context: BuilderContext) {} constructor(private context: BuilderContext) {}
run(builderConfig: BuilderConfiguration<Partial<Schema>>): Observable<BuildEvent> { run(builderConfig: BuilderConfiguration<Partial<Schema>>): Observable<BuildEvent> {
@ -38,3 +38,5 @@ export class Builder implements BuilderInterface<Schema> {
.pipe(map(() => ({success: true})), catchError(() => of ({success: false})), ); .pipe(map(() => ({success: true})), catchError(() => of ({success: false})), );
} }
} }
export default BazelBuilder;

View File

@ -15,6 +15,7 @@ jasmine_node_test(
bootstrap = ["angular/tools/testing/init_node_spec.js"], bootstrap = ["angular/tools/testing/init_node_spec.js"],
deps = [ deps = [
"//packages/bazel/src/schematics/bazel-workspace:test", "//packages/bazel/src/schematics/bazel-workspace:test",
"//packages/bazel/src/schematics/ng-new:test",
"//tools/testing:node", "//tools/testing:node",
], ],
) )

View File

@ -7,7 +7,15 @@ load("@build_bazel_rules_typescript//:defs.bzl", "ts_devserver")
ng_module( ng_module(
name = "src", name = "src",
srcs = glob(["**/*.ts"], exclude = ["**/*.spec.ts", "test.ts"]), srcs = glob(
include = ["**/*.ts"],
exclude = [
"**/*.spec.ts",
"main.ts",
"test.ts",
"initialize_testbed.ts",
],
),
assets = glob([ assets = glob([
"**/*.css", "**/*.css",
"**/*.html", "**/*.html",
@ -21,7 +29,7 @@ ng_module(
rollup_bundle( rollup_bundle(
name = "bundle", name = "bundle",
entry_point = "src/main", entry_point = "src/main.prod",
deps = ["//src"], deps = ["//src"],
) )
@ -50,7 +58,7 @@ ts_devserver(
"npm/node_modules/zone.js/dist", "npm/node_modules/zone.js/dist",
"npm/node_modules/tslib", "npm/node_modules/tslib",
], ],
entry_module = "<%= name %>/src/main", entry_module = "<%= name %>/src/main.dev",
serving_path = "/bundle.min.js", serving_path = "/bundle.min.js",
static_files = [ static_files = [
"@npm//node_modules/zone.js:dist/zone.min.js", "@npm//node_modules/zone.js:dist/zone.min.js",
@ -67,14 +75,28 @@ ts_library(
deps = [ deps = [
":src", ":src",
"@angular//packages/core/testing", "@angular//packages/core/testing",
"@angular//packages/platform-browser-dynamic/testing",
"@npm//@types", "@npm//@types",
], ],
) )
ts_library(
name = "initialize_testbed",
testonly = 1,
srcs = [
"initialize_testbed.ts",
],
deps = [
"@angular//packages/core/testing",
"@angular//packages/platform-browser-dynamic/testing",
],
)
ts_web_test_suite( ts_web_test_suite(
name = "test", name = "test",
srcs = ["@npm//node_modules/tslib:tslib.js"], srcs = ["@npm//node_modules/tslib:tslib.js"],
runtime_deps = [
":initialize_testbed",
],
# do not sort # do not sort
bootstrap = [ bootstrap = [
"@npm//node_modules/zone.js:dist/zone-testing-bundle.js", "@npm//node_modules/zone.js:dist/zone-testing-bundle.js",

View File

@ -0,0 +1,9 @@
/**
* @fileoverview Provides a script to initialize TestBed before tests are run.
* This file should be included in the "runtime_deps" of a "ts_web_test_suite"
* rule.
*/
import {TestBed} from '@angular/core/testing';
import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());

View File

@ -30,7 +30,7 @@ export default function(options: BazelWorkspaceOptions): Rule {
const appDir = `${newProjectRoot}/${options.name}`; const appDir = `${newProjectRoot}/${options.name}`;
const workspaceVersions = { const workspaceVersions = {
'ANGULAR_VERSION': '7.0.2', 'ANGULAR_VERSION': '7.1.0',
'RULES_SASS_VERSION': '1.14.1', 'RULES_SASS_VERSION': '1.14.1',
'RXJS_VERSION': '6.3.3', 'RXJS_VERSION': '6.3.3',
}; };

View File

@ -2,6 +2,11 @@
"name": "@angular/bazel", "name": "@angular/bazel",
"version": "0.1", "version": "0.1",
"schematics": { "schematics": {
"ng-new": {
"factory": "./ng-new",
"schema": "./ng-new/schema.json",
"description": "Create an Angular project that builds with Bazel."
},
"bazel-workspace": { "bazel-workspace": {
"factory": "./bazel-workspace", "factory": "./bazel-workspace",
"schema": "./bazel-workspace/schema.json", "schema": "./bazel-workspace/schema.json",

View File

@ -0,0 +1,36 @@
package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "ng-new",
srcs = [
"index.ts",
"schema.d.ts",
],
data = glob(["files/**/*"]) + [
"schema.json",
],
deps = [
"//packages/bazel/src/schematics/bazel-workspace",
"@ngdeps//@angular-devkit/core",
"@ngdeps//@angular-devkit/schematics",
"@ngdeps//@schematics/angular",
"@ngdeps//typescript",
],
)
ts_library(
name = "test",
testonly = True,
srcs = [
"index_spec.ts",
],
data = [
"//packages/bazel/src/schematics:package_assets",
],
deps = [
":ng-new",
"@ngdeps//@angular-devkit/schematics",
],
)

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><%= utils.classify(name) %></title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
<script src="/zone.min.js"></script>
<script src="/bundle.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,4 @@
import {platformBrowser} from '@angular/platform-browser';
import {AppModuleNgFactory} from './app/app.module.ngfactory';
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

View File

@ -0,0 +1,6 @@
import {enableProdMode} from '@angular/core';
import {platformBrowser} from '@angular/platform-browser';
import {AppModuleNgFactory} from './app/app.module.ngfactory';
enableProdMode();
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

View File

@ -0,0 +1,180 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*
* @fileoverview Schematics for ng-new project that builds with Bazel.
*/
import {apply, applyTemplates, chain, externalSchematic, MergeStrategy, mergeWith, move, Rule, schematic, Tree, url, SchematicsException, UpdateRecorder,} from '@angular-devkit/schematics';
import {parseJsonAst, JsonAstObject, strings, JsonValue} from '@angular-devkit/core';
import {findPropertyInAstObject, insertPropertyInAstObjectInOrder} from '@schematics/angular/utility/json-utils';
import {validateProjectName} from '@schematics/angular/utility/validation';
import {getWorkspace} from '@schematics/angular/utility/config';
import {Schema} from './schema';
function addDevDependenciesToPackageJson(options: Schema) {
return (host: Tree) => {
const packageJson = `${options.name}/package.json`;
if (!host.exists(packageJson)) {
throw new Error(`Could not find ${packageJson}`);
}
const packageJsonContent = host.read(packageJson);
if (!packageJsonContent) {
throw new Error('Failed to read package.json content');
}
const jsonAst = parseJsonAst(packageJsonContent.toString()) as JsonAstObject;
const deps = findPropertyInAstObject(jsonAst, 'dependencies') as JsonAstObject;
const devDeps = findPropertyInAstObject(jsonAst, 'devDependencies') as JsonAstObject;
const angularCoreNode = findPropertyInAstObject(deps, '@angular/core');
const angularCoreVersion = angularCoreNode !.value as string;
const devDependencies: {[k: string]: string} = {
'@angular/bazel': angularCoreVersion,
'@bazel/karma': '0.21.0',
'@bazel/typescript': '0.21.0',
};
const recorder = host.beginUpdate(packageJson);
for (const packageName of Object.keys(devDependencies)) {
const version = devDependencies[packageName];
const indent = 4;
insertPropertyInAstObjectInOrder(recorder, devDeps, packageName, version, indent);
}
host.commitUpdate(recorder);
return host;
};
}
function overwriteMainAndIndex(options: Schema) {
return (host: Tree) => {
let newProjectRoot = '';
try {
const workspace = getWorkspace(host);
newProjectRoot = workspace.newProjectRoot || '';
} catch {
}
const srcDir = `${newProjectRoot}/${options.name}/src`;
return mergeWith(
apply(
url('./files'),
[
applyTemplates({
utils: strings,
...options,
'dot': '.',
}),
move(srcDir),
]),
MergeStrategy.Overwrite);
};
}
function replacePropertyInAstObject(
recorder: UpdateRecorder, node: JsonAstObject, propertyName: string, value: JsonValue,
indent: number) {
const property = findPropertyInAstObject(node, propertyName);
if (property === null) {
throw new Error(`Property ${propertyName} does not exist in JSON object`);
}
const {start, text} = property;
recorder.remove(start.offset, text.length);
const indentStr = '\n' +
' '.repeat(indent);
const content = JSON.stringify(value, null, ' ').replace(/\n/g, indentStr);
recorder.insertLeft(start.offset, content);
}
function updateWorkspaceFileToUseBazelBuilder(options: Schema): Rule {
return (host: Tree, context: SchematicContext) => {
const {name} = options;
const workspacePath = `${name}/angular.json`;
if (!host.exists(workspacePath)) {
throw new SchematicsException(`Workspace file ${workspacePath} not found.`);
}
const workspaceBuffer = host.read(workspacePath) !;
const workspaceJsonAst = parseJsonAst(workspaceBuffer.toString()) as JsonAstObject;
const projects = findPropertyInAstObject(workspaceJsonAst, 'projects');
if (!projects) {
throw new SchematicsException('Expect projects in angular.json to be an Object');
}
const project = findPropertyInAstObject(projects as JsonAstObject, name);
if (!project) {
throw new SchematicsException(`Expected projects to contain ${name}`);
}
const recorder = host.beginUpdate(workspacePath);
const indent = 6;
replacePropertyInAstObject(
recorder, project as JsonAstObject, 'architect', {
'build': {
'builder': '@angular/bazel:build',
'options': {'targetLabel': '//src:bundle.js', 'bazelCommand': 'build'},
'configurations': {'production': {'targetLabel': '//src:bundle'}}
},
'serve': {
'builder': '@angular/bazel:build',
'options': {'targetLabel': '//src:devserver', 'bazelCommand': 'run'},
'configurations': {'production': {'targetLabel': '//src:prodserver'}}
},
'extract-i18n': {
'builder': '@angular-devkit/build-angular:extract-i18n',
'options': {'browserTarget': `${name}:build`}
},
'test': {
'builder': '@angular/bazel:build',
'options': {'bazelCommand': 'test', 'targetLabel': '//src/...'}
},
'lint': {
'builder': '@angular-devkit/build-angular:tslint',
'options': {
'tsConfig': ['src/tsconfig.app.json', 'src/tsconfig.spec.json'],
'exclude': ['**/node_modules/**']
}
}
},
indent);
const e2e = `${options.name}-e2e`;
const e2eNode = findPropertyInAstObject(projects as JsonAstObject, e2e);
if (e2eNode) {
replacePropertyInAstObject(
recorder, e2eNode as JsonAstObject, 'architect', {
'e2e': {
'builder': '@angular/bazel:build',
'options': {'bazelCommand': 'test', 'targetLabel': '//e2e:devserver_test'},
'configurations': {'production': {'targetLabel': '//e2e:prodserver_test'}}
},
'lint': {
'builder': '@angular-devkit/build-angular:tslint',
'options': {'tsConfig': 'e2e/tsconfig.e2e.json', 'exclude': ['**/node_modules/**']}
}
},
indent);
}
host.commitUpdate(recorder);
return host;
};
}
export default function(options: Schema): Rule {
return (host: Tree) => {
validateProjectName(options.name);
return chain([
externalSchematic('@schematics/angular', 'ng-new', {
...options,
skipInstall: true,
}),
addDevDependenciesToPackageJson(options),
schematic('bazel-workspace', options),
overwriteMainAndIndex(options),
updateWorkspaceFileToUseBazelBuilder(options),
]);
};
}

View File

@ -0,0 +1,88 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {SchematicTestRunner} from '@angular-devkit/schematics/testing';
describe('Ng-new Schematic', () => {
const schematicRunner =
new SchematicTestRunner('@angular/bazel', require.resolve('../collection.json'), );
const defaultOptions = {
name: 'demo',
version: '7.0.0',
};
it('should call external @schematics/angular', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
// External schematic should produce workspace file angular.json
expect(files).toContain('/demo/angular.json');
});
it('should add @angular/bazel to package.json dependencies', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
expect(files).toContain('/demo/package.json');
const content = host.readContent('/demo/package.json');
expect(() => JSON.parse(content)).not.toThrow();
const json = JSON.parse(content);
const core = '@angular/core';
const bazel = '@angular/bazel';
expect(Object.keys(json)).toContain('dependencies');
expect(Object.keys(json)).toContain('devDependencies');
expect(Object.keys(json.dependencies)).toContain(core);
expect(Object.keys(json.devDependencies)).toContain(bazel);
expect(json.dependencies[core]).toBe(json.devDependencies[bazel]);
});
it('should create Bazel workspace file', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
expect(files).toContain('/demo/WORKSPACE');
expect(files).toContain('/demo/BUILD.bazel');
});
it('should produce main.prod.ts for AOT', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
// main.prod.ts is used by Bazel for AOT
expect(files).toContain('/demo/src/main.prod.ts');
// main.ts is produced by original ng-new schematics
// This file should be present for backwards compatibility.
expect(files).toContain('/demo/src/main.ts');
});
it('should overwrite index.html with script tags', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
expect(files).toContain('/demo/src/index.html');
const content = host.readContent('/demo/src/index.html');
expect(content).toMatch('<script src="/zone.min.js"></script>');
expect(content).toMatch('<script src="/bundle.min.js"></script>');
});
it('should update angular.json to use Bazel builder', () => {
const options = {...defaultOptions};
const host = schematicRunner.runSchematic('ng-new', options);
const {files} = host;
expect(files).toContain('/demo/angular.json');
const content = host.readContent('/demo/angular.json');
expect(() => JSON.parse(content)).not.toThrow();
const json = JSON.parse(content);
let {architect} = json.projects.demo;
expect(architect.build.builder).toBe('@angular/bazel:build');
expect(architect.serve.builder).toBe('@angular/bazel:build');
expect(architect.test.builder).toBe('@angular/bazel:build');
architect = json.projects['demo-e2e'].architect;
expect(architect.e2e.builder).toBe('@angular/bazel:build');
});
});

View File

@ -0,0 +1,103 @@
// THIS FILE IS AUTOMATICALLY GENERATED. TO UPDATE THIS FILE YOU NEED TO CHANGE THE
// CORRESPONDING JSON SCHEMA FILE, THEN RUN devkit-admin build (or bazel build ...).
// tslint:disable:no-global-tslint-disable
// tslint:disable
export interface Schema {
/**
* Initial repository commit information.
*/
commit?: CommitUnion;
/**
* Flag to toggle creation of an application in the new workspace.
*/
createApplication?: boolean;
/**
* The directory name to create the workspace in.
*/
directory?: string;
/**
* EXPERIMENTAL: Specifies whether to create a new application which uses the Ivy rendering
* engine.
*/
experimentalIvy?: boolean;
/**
* Specifies if the style will be in the ts file.
*/
inlineStyle?: boolean;
/**
* Specifies if the template will be in the ts file.
*/
inlineTemplate?: boolean;
/**
* Link CLI to global version (internal development only).
*/
linkCli?: boolean;
/**
* Create a barebones project without any testing frameworks
*/
minimal?: boolean;
/**
* The name of the workspace.
*/
name: string;
/**
* The path where new projects will be created.
*/
newProjectRoot?: string;
/**
* The prefix to apply to generated selectors.
*/
prefix?: string;
/**
* Generates a routing module.
*/
routing?: boolean;
/**
* Skip initializing a git repository.
*/
skipGit?: boolean;
/**
* Skip installing dependency packages.
*/
skipInstall?: boolean;
/**
* Skip creating spec files.
*/
skipTests?: boolean;
/**
* The file extension to be used for style files.
*/
style?: string;
/**
* The version of the Angular CLI to use.
*/
version: string;
/**
* Specifies the view encapsulation strategy.
*/
viewEncapsulation?: ViewEncapsulation;
}
/**
* Initial repository commit information.
*/
export type CommitUnion = boolean | CommitObject;
export interface CommitObject {
email: string;
message?: string;
name: string;
}
/**
* Specifies the view encapsulation strategy.
*/
export enum ViewEncapsulation {
Emulated = 'Emulated',
Native = 'Native',
None = 'None',
ShadowDOM = 'ShadowDom',
}

View File

@ -0,0 +1,150 @@
{
"$schema": "http://json-schema.org/schema",
"id": "SchematicsAngularNgNew",
"title": "Angular Ng New Options Schema",
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "The directory name to create the workspace in."
},
"name": {
"description": "The name of the workspace.",
"type": "string",
"format": "html-selector",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "What name would you like to use for the project?"
},
"experimentalIvy": {
"description": "EXPERIMENTAL: Specifies whether to create a new application which uses the Ivy rendering engine.",
"type": "boolean",
"default": false
},
"skipInstall": {
"description": "Skip installing dependency packages.",
"type": "boolean",
"default": false
},
"linkCli": {
"description": "Link CLI to global version (internal development only).",
"type": "boolean",
"default": false,
"visible": false
},
"skipGit": {
"description": "Skip initializing a git repository.",
"type": "boolean",
"default": false,
"alias": "g"
},
"commit": {
"description": "Initial repository commit information.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"message": {
"type": "string"
}
},
"required": [
"name",
"email"
]
}
],
"default": true
},
"newProjectRoot": {
"description": "The path where new projects will be created.",
"type": "string",
"default": "projects"
},
"inlineStyle": {
"description": "Specifies if the style will be in the ts file.",
"type": "boolean",
"default": false,
"alias": "s"
},
"inlineTemplate": {
"description": "Specifies if the template will be in the ts file.",
"type": "boolean",
"default": false,
"alias": "t"
},
"viewEncapsulation": {
"description": "Specifies the view encapsulation strategy.",
"enum": ["Emulated", "Native", "None", "ShadowDom"],
"type": "string"
},
"version": {
"type": "string",
"description": "The version of the Angular CLI to use.",
"visible": false,
"$default": {
"$source": "ng-cli-version"
}
},
"routing": {
"type": "boolean",
"description": "Generates a routing module.",
"default": false,
"x-prompt": "Would you like to add Angular routing?"
},
"prefix": {
"type": "string",
"format": "html-selector",
"description": "The prefix to apply to generated selectors.",
"minLength": 1,
"default": "app",
"alias": "p"
},
"style": {
"description": "The file extension to be used for style files.",
"type": "string",
"default": "css",
"x-prompt": {
"message": "Which stylesheet format would you like to use?",
"type": "list",
"items": [
{ "value": "css", "label": "CSS" },
{ "value": "scss", "label": "SCSS [ http://sass-lang.com ]" },
{ "value": "sass", "label": "SASS [ http://sass-lang.com ]" },
{ "value": "less", "label": "LESS [ http://lesscss.org ]" },
{ "value": "styl", "label": "Stylus [ http://stylus-lang.com ]" }
]
}
},
"skipTests": {
"description": "Skip creating spec files.",
"type": "boolean",
"default": false,
"alias": "S"
},
"createApplication": {
"description": "Flag to toggle creation of an application in the new workspace.",
"type": "boolean",
"default": true
},
"minimal": {
"description": "Create a barebones project without any testing frameworks",
"type": "boolean",
"default": false
}
},
"required": [
"name",
"version"
]
}