diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000000..b0193c368b --- /dev/null +++ b/.bazelrc @@ -0,0 +1,8 @@ +# Disable sandboxing because it's too slow. +# https://github.com/bazelbuild/bazel/issues/2424 +# TODO(alexeagle): do use the sandbox on CI +#build --spawn_strategy=standalone + +# Performance: avoid stat'ing input files +build --watchfs + diff --git a/.gitignore b/.gitignore index 00edd8eb80..8297c37d03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Outputs +bazel-* build/ dist/ @@ -11,6 +12,7 @@ jsconfig.json typings/ # Misc +coverage/ node_modules/ tmp/ npm-debug.log* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..62f9457511 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +6 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..d4cdd89768 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +dist: trusty + +language: node_js + +env: + global: + - DBUS_SESSION_BUS_ADDRESS=/dev/null + +matrix: + fast_finish: true + include: + - node_js: "6" + os: linux + env: SCRIPT=lint + - node_js: "6" + os: linux + env: SCRIPT=test + +before_install: + # Install yarn. + - curl -o- -L https://yarnpkg.com/install.sh | bash + - export PATH="$HOME/.yarn/bin:$PATH" + - yarn config set spin false + - yarn config set progress false + +script: + - if [[ "$SCRIPT" ]]; then npm run $SCRIPT; fi diff --git a/BUILD b/BUILD new file mode 100644 index 0000000000..283d6693d6 --- /dev/null +++ b/BUILD @@ -0,0 +1,5 @@ +package(default_visibility = ["//visibility:public"]) +exports_files(["tsconfig.json"]) + +# NOTE: this will move to node_modules/BUILD in a later release +filegroup(name = "node_modules", srcs = glob(["node_modules/**/*"])) diff --git a/WORKSPACE b/WORKSPACE new file mode 100644 index 0000000000..263a80bf79 --- /dev/null +++ b/WORKSPACE @@ -0,0 +1,12 @@ +load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") + +git_repository( + name = "io_bazel_rules_typescript", + remote = "https://github.com/bazelbuild/rules_typescript.git", + tag = "0.0.2", +) + +load("@io_bazel_rules_typescript//:defs.bzl", "node_repositories", "yarn_install") + +node_repositories() +yarn_install(package_json = "//:package.json") diff --git a/bin/devkit-admin b/bin/devkit-admin new file mode 100755 index 0000000000..e2ada92892 --- /dev/null +++ b/bin/devkit-admin @@ -0,0 +1,18 @@ +#!/usr/bin/env node +'use strict'; + +/** + * This file is useful for not having to load bootstrap-local in various javascript. + * Simply use package.json to have npm scripts that use this script as well, or use + * this script directly. + */ + +require('../lib/bootstrap-local'); + +const minimist = require('minimist'); +const path = require('path'); + +const args = minimist(process.argv.slice(2)); +const scriptName = path.join('../scripts', args._.shift()); + +require(scriptName).default(args); diff --git a/bin/schematics b/bin/schematics new file mode 100755 index 0000000000..cc49c355a6 --- /dev/null +++ b/bin/schematics @@ -0,0 +1,6 @@ +#!/usr/bin/env node +'use strict'; +require('../lib/bootstrap-local'); +const packages = require('../lib/packages').packages; + +require(packages['@angular/schematics-cli'].bin['schematics']); diff --git a/lib/bootstrap-local.js b/lib/bootstrap-local.js new file mode 100644 index 0000000000..a9f27a80b3 --- /dev/null +++ b/lib/bootstrap-local.js @@ -0,0 +1,90 @@ +/** + * @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 + */ +/* eslint-disable no-console */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + + +Error.stackTraceLimit = Infinity; + +global._DevKitIsLocal = true; +global._DevKitRoot = path.resolve(__dirname, '..'); +global._DevKitPackages = require('./packages').packages; +global._DevKitTools = require('./packages').tools; + +global._DevKitRequireHook = null; + + +const compilerOptions = ts.readConfigFile(path.join(__dirname, '../tsconfig.json'), p => { + return fs.readFileSync(p, 'utf-8'); +}).config; + + +const oldRequireTs = require.extensions['.ts']; +require.extensions['.ts'] = function (m, filename) { + // If we're in node module, either call the old hook or simply compile the + // file without transpilation. We do not touch node_modules/**. + // We do touch `Angular DevK` files anywhere though. + if (!filename.match(/@angular\/cli\b/) && filename.match(/node_modules/)) { + if (oldRequireTs) { + return oldRequireTs(m, filename); + } + return m._compile(fs.readFileSync(filename), filename); + } + + // Node requires all require hooks to be sync. + const source = fs.readFileSync(filename).toString(); + + try { + let result = ts.transpile(source, compilerOptions['compilerOptions'], filename); + + if (global._DevKitRequireHook) { + result = global._DevKitRequireHook(result, filename); + } + + // Send it to node to execute. + return m._compile(result, filename); + } catch (err) { + console.error('Error while running script "' + filename + '":'); + console.error(err.stack); + throw err; + } +}; + + +// If we're running locally, meaning npm linked. This is basically "developer mode". +if (!__dirname.match(new RegExp(`\\${path.sep}node_modules\\${path.sep}`))) { + const packages = require('./packages').packages; + + // We mock the module loader so that we can fake our packages when running locally. + const Module = require('module'); + const oldLoad = Module._load; + const oldResolve = Module._resolveFilename; + + Module._resolveFilename = function (request, parent) { + if (request in packages) { + return packages[request].main; + } else if (request.startsWith('@angular/cli/')) { + // We allow deep imports (for now). + // TODO: move tests to inside @angular/cli package so they don't have to deep import. + const dir = path.dirname(parent.filename); + return path.relative(dir, path.join(__dirname, '../packages', request)); + } else { + let match = Object.keys(packages).find(pkgName => request.startsWith(pkgName + '/')); + if (match) { + const p = path.join(packages[match].root, request.substr(match.length)); + return oldResolve.call(this, p, parent); + } else { + return oldResolve.apply(this, arguments); + } + } + }; +} diff --git a/lib/packages.js b/lib/packages.js new file mode 100644 index 0000000000..f424b71e25 --- /dev/null +++ b/lib/packages.js @@ -0,0 +1,72 @@ +/** + * @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 + */ +'use strict'; + +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +const packageRoot = path.join(__dirname, '../packages'); +const toolsRoot = path.join(__dirname, '../tools'); +const distRoot = path.join(__dirname, '../dist'); + + +// All the supported packages. Go through the packages directory and create a _map of +// name => fullPath. +const packages = + [].concat( + glob.sync(path.join(packageRoot, '*/package.json')), + glob.sync(path.join(packageRoot, '*/*/package.json'))) + .filter(p => !p.match(/blueprints/)) + .map(pkgPath => [pkgPath, path.relative(packageRoot, path.dirname(pkgPath))]) + .map(([pkgPath, pkgName]) => { + return { name: pkgName, root: path.dirname(pkgPath) }; + }) + .reduce((packages, pkg) => { + let pkgJson = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8')); + let name = pkgJson['name']; + let bin = {}; + Object.keys(pkgJson['bin'] || {}).forEach(binName => { + bin[binName] = path.resolve(pkg.root, pkgJson['bin'][binName]); + }); + + packages[name] = { + dist: path.join(distRoot, pkg.name), + packageJson: path.join(pkg.root, 'package.json'), + root: pkg.root, + relative: path.relative(path.dirname(__dirname), pkg.root), + main: path.resolve(pkg.root, 'src/index.ts'), + bin: bin + }; + return packages; + }, {}); + +const tools = glob.sync(path.join(toolsRoot, '**/package.json')) + .map(toolPath => path.relative(toolsRoot, path.dirname(toolPath))) + .map(toolName => { + const root = path.join(toolsRoot, toolName); + const pkgJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const name = pkgJson['name']; + const dist = path.join(distRoot, toolName); + + return { + name, + main: path.join(dist, pkgJson['main']), + mainTs: path.join(toolsRoot, toolName, pkgJson['main'].replace(/\.js$/, '.ts')), + root, + packageJson: path.join(toolsRoot, toolName, 'package.json'), + dist + }; + }) + .reduce((tools, tool) => { + tools[tool.name] = tool; + return tools; + }, {}); + + +module.exports = { packages, tools }; diff --git a/package.json b/package.json index c9c67e9e07..b2a7647eb5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,22 @@ { - "name": "@angular/sdk", + "name": "@angular/devkit", "version": "0.0.0", "description": "Software Development Kit for Angular", "bin": { - "sdk-admin": "./bin/sdk-admin" + "devkit-admin": "./bin/devkit-admin", + "schematics": "./bin/schematics" }, "keywords": [], - "scripts": {}, + "scripts": { + "admin": "./bin/devkit-admin", + "build": "tsc -p tsconfig.json", + "fix": "npm run lint -- --fix", + "lint": "tslint --config tslint.json --project tsconfig.json --type-check", + "test": "./bin/devkit-admin test" + }, "repository": { "type": "git", - "url": "https://github.com/angular/sdk.git" + "url": "https://github.com/angular/devkit.git" }, "engines": { "node": ">= 6.9.0", @@ -18,32 +25,38 @@ "author": "Angular Authors", "license": "MIT", "bugs": { - "url": "https://github.com/angular/sdk/issues" + "url": "https://github.com/angular/devkit/issues" }, - "homepage": "https://github.com/angular/sdk", + "homepage": "https://github.com/angular/devkit", "dependencies": { + "@ngtools/json-schema": "^1.0.9", + "@ngtools/logger": "^1.0.1", "@types/chalk": "^0.4.28", "@types/common-tags": "^1.2.4", "@types/glob": "^5.0.29", - "@types/jasmine": "~2.2.0", + "@types/istanbul": "^0.4.29", + "@types/jasmine": "^2.5.47", "@types/minimist": "^1.2.0", "@types/node": "^6.0.36", "@types/semver": "^5.3.30", - "chai": "^3.5.0", + "@types/source-map": "^0.5.0", "chalk": "^1.1.3", "common-tags": "^1.3.1", "conventional-changelog": "^1.1.0", "glob": "^7.0.3", - "jasmine": "^2.4.1", + "istanbul": "^0.4.5", + "jasmine": "^2.6.0", "jasmine-spec-reporter": "^3.2.0", - "minimatch": "^3.0.3", + "marked-terminal": "^2.0.0", "minimist": "^1.2.0", "npm-run": "^4.1.0", "npm-run-all": "^4.0.0", "rxjs": "^5.0.1", "semver": "^5.3.0", + "source-map": "^0.5.6", + "temp": "^0.8.3", "ts-node": "^2.0.0", "tslint": "^4.0.2", - "typescript": "~2.2.0" + "typescript": "~2.3.0" } } diff --git a/packages/schematics/BUILD b/packages/schematics/BUILD new file mode 100644 index 0000000000..5f90250d39 --- /dev/null +++ b/packages/schematics/BUILD @@ -0,0 +1,24 @@ +package(default_visibility=["//visibility:private"]) +load("@io_bazel_rules_typescript//:defs.bzl", "ts_library") + +exports_files(["tsconfig.json"]) + + +ts_library( + name = "schematics", + srcs = [ + "src/index.ts", + ], + deps = [ + "//packages/schematics/src/engine", + "//packages/schematics/src/exception", + "//packages/schematics/src/rules", + "//packages/schematics/src/sink", + "//packages/schematics/src/tree", + "//packages/schematics/tooling", + ], + tsconfig = "//:tsconfig.json", + visibility = [ "//visibility:public" ], + module_name = "@angular/schematics", + module_root = "src" +) diff --git a/packages/schematics/README.md b/packages/schematics/README.md new file mode 100644 index 0000000000..8f95e63301 --- /dev/null +++ b/packages/schematics/README.md @@ -0,0 +1,69 @@ +# Schematics +> A scaffolding library for the modern web. + +## Description +Schematics are generators that transform an existing filesystem. It can create files, refactor existing files, or move files around. + +What distinguish Schematics from other generators, such as Yeoman or Yarn Create, is that schematics are purely descriptive; no changes are applied to the actual filesystem until everything is ready to be committed. There is no side effect, by design, in Schematics. + +# Glossary + +| Term | Description | +|------|-------------| +| **Schematics** | A generator that execute descriptive code without side effects on an existing file system. | +| **Collection** | A list of schematics metadata. Schematics can be referred by name inside a collection. | +| **Tool** | The code using the Schematics library. | +| **Tree** | A staging area for changes, containing the original file system, and a list of changes to apply to it. | +| **Rule** | A function that applies actions to a `Tree`. It returns a new Tree that will contain all transformations to be applied. | +| **Source** | A function that creates an entirely new `Tree` from an empty filesystem. For example, a file source could read files from disk and create a Create Action for each of those. +| **Action** | A atomic operation to be validated and committed to a filesystem or a `Tree`. Actions are created by schematics. | +| **Sink** | The final destination of all `Action`s. | + +# Tooling +Schematics is a library, and does not work by itself. A reference CLI is available in [`@angular/schematics-cli`](../schematics_cli/README.md). This document explain the library usage and the tooling API, but does not go into the tool implementation itself. + +The tooling is responsible for the following tasks: + +1. Create the Schematic Engine, and pass in a Collection and Schematic loader. +1. Understand and respect the Schematics metadata and dependencies between collections. Schematics can refer to dependencies, and it's the responsibility of the tool to honor those dependencies. The reference CLI uses NPM packages for its collections. +1. Create the Options object. Options can be anything, but the schematics can specify a JSON Schema that should be respected. The reference CLI, for example, parse the arguments as a JSON object and validate it with the Schema specified by the collection. +1. Call the schematics with the original Tree. The tree should represent the initial state of the filesystem. The reference CLI uses the current directory for this. +1. Create a Sink and commit the result of the schematics to the Sink. Many sinks are provided by the library; FileSystemSink and DryRunSink are examples. +1. Output any logs propagated by the library, including debugging information. + +The tooling API is composed of the following pieces: + +## Engine +The `SchematicEngine` is responsible for loading and constructing `Collection`s and `Schematics`'. When creating an engine, the tooling provides an `EngineHost` interface that understands how to create a `CollectionDescription` from a name, and how to create a `Schematic + +# Examples + +## Simple +An example of a simple Schematics which creates a "hello world" file, using an option to determine its path: + +```typescript +import {Tree} from '@angular/schematics'; + +export default function MySchematic(options: any) { + return (tree: Tree) => { + tree.create(options.path + '/hi', 'Hello world!'); + return tree; + }; +} +``` + +A few things from this example: + +1. The function receives the list of options from the tooling. +1. It returns a [`Rule`](src/engine/interface.ts#L73), which is a transformation from a `Tree` to another `Tree`. + + + +# Future Work + +Schematics is not done yet. Here's a list of things we are considering: + +* Smart defaults for Options. Having a JavaScript function for default values based on other default values. +* Prompt for input options. This should only be prompted for the original schematics, dependencies to other schematics should not trigger another prompting. +* Tasks for running tooling-specific jobs before and after a schematics has been scaffolded. Such tasks can involve initialize git, or npm install. A specific list of tasks should be provided by the tool, with unsupported tasks generating an error. + diff --git a/packages/schematics/package.json b/packages/schematics/package.json new file mode 100644 index 0000000000..61b3ec86f4 --- /dev/null +++ b/packages/schematics/package.json @@ -0,0 +1,31 @@ +{ + "name": "@angular/schematics", + "version": "0.0.0", + "description": "CLI tool for Angular", + "main": "src/index.js", + "typings": "src/index.d.ts", + "keywords": [ + "angular", + "sdk", + "blueprints", + "code generation", + "schematics", + "Angular DevKit" + ], + "repository": { + "type": "git", + "url": "https://github.com/angular/devkit.git" + }, + "engines": { + "node": ">= 6.9.0", + "npm": ">= 3.0.0" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/devkit/issues" + }, + "homepage": "https://github.com/angular/devkit", + "dependencies": { + } +} diff --git a/packages/schematics/src/engine/BUILD b/packages/schematics/src/engine/BUILD new file mode 100644 index 0000000000..f29066644e --- /dev/null +++ b/packages/schematics/src/engine/BUILD @@ -0,0 +1,13 @@ +package(default_visibility=["//visibility:public"]) +load("@io_bazel_rules_typescript//:defs.bzl", "ts_library") + + +ts_library( + name = "engine", + srcs = glob(["*.ts"]), + deps = [ + "//packages/schematics/src/exception", + "//packages/schematics/src/tree", + ], + tsconfig = "//:tsconfig.json", +) diff --git a/packages/schematics/src/engine/collection.ts b/packages/schematics/src/engine/collection.ts new file mode 100644 index 0000000000..ecfc32af0b --- /dev/null +++ b/packages/schematics/src/engine/collection.ts @@ -0,0 +1,24 @@ +/** + * @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 {SchematicEngine} from './engine'; +import {Collection, CollectionDescription, Schematic} from './interface'; + + +export class CollectionImpl + implements Collection { + constructor(private _description: CollectionDescription, + private _engine: SchematicEngine) { + } + + get description() { return this._description; } + get name() { return this.description.name || ''; } + + createSchematic(name: string): Schematic { + return this._engine.createSchematic(name, this); + } +} diff --git a/packages/schematics/src/engine/engine.ts b/packages/schematics/src/engine/engine.ts new file mode 100644 index 0000000000..05372e42f5 --- /dev/null +++ b/packages/schematics/src/engine/engine.ts @@ -0,0 +1,109 @@ +/** + * @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 {CollectionImpl} from './collection'; +import { + Collection, + Engine, + EngineHost, + Schematic, + Source, + TypedSchematicContext +} from './interface'; +import {SchematicImpl} from './schematic'; +import {BaseException} from '../exception/exception'; +import {MergeStrategy} from '../tree/interface'; +import {NullTree} from '../tree/null'; +import {branch, empty} from '../tree/static'; + +import {Url} from 'url'; +import 'rxjs/add/operator/map'; + + +export class UnknownUrlSourceProtocol extends BaseException { + constructor(url: string) { super(`Unknown Protocol on url "${url}".`); } +} + +export class UnknownCollectionException extends BaseException { + constructor(name: string) { super(`Unknown collection "${name}".`); } +} +export class UnknownSchematicException extends BaseException { + constructor(name: string, collection: Collection) { + super(`Schematic "${name}" not found in collection "${collection.description.name}".`); + } +} + + +export class SchematicEngine implements Engine { + private _collectionCache = new Map>(); + private _schematicCache + = new Map>>(); + + constructor(private _host: EngineHost) { + } + + get defaultMergeStrategy() { return this._host.defaultMergeStrategy || MergeStrategy.Default; } + + createCollection(name: string): Collection { + let collection = this._collectionCache.get(name); + if (collection) { + return collection; + } + + const description = this._host.createCollectionDescription(name); + if (!description) { + throw new UnknownCollectionException(name); + } + + collection = new CollectionImpl(description, this); + this._collectionCache.set(name, collection); + this._schematicCache.set(name, new Map()); + return collection; + } + + createSchematic( + name: string, + collection: Collection): Schematic { + const collectionImpl = this._collectionCache.get(collection.description.name); + const schematicMap = this._schematicCache.get(collection.description.name); + if (!collectionImpl || !schematicMap || collectionImpl !== collection) { + // This is weird, maybe the collection was created by another engine? + throw new UnknownCollectionException(collection.description.name); + } + + let schematic = schematicMap.get(name); + if (schematic) { + return schematic; + } + + const description = this._host.createSchematicDescription(name, collection.description); + if (!description) { + throw new UnknownSchematicException(name, collection); + } + const factory = this._host.getSchematicRuleFactory(description, collection.description); + schematic = new SchematicImpl(description, factory, collection, this); + + schematicMap.set(name, schematic); + return schematic; + } + + createSourceFromUrl(url: Url): Source { + switch (url.protocol) { + case 'null:': return () => new NullTree(); + case 'empty:': return () => empty(); + case 'host:': return (context: TypedSchematicContext) => { + return context.host.map(tree => branch(tree)); + }; + default: + const hostSource = this._host.createSourceFromUrl(url); + if (!hostSource) { + throw new UnknownUrlSourceProtocol(url.toString()); + } + return hostSource; + } + } +} diff --git a/packages/schematics/src/engine/interface.ts b/packages/schematics/src/engine/interface.ts new file mode 100644 index 0000000000..cb1f315617 --- /dev/null +++ b/packages/schematics/src/engine/interface.ts @@ -0,0 +1,136 @@ +/** + * @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 {MergeStrategy, Tree} from '../tree/interface'; + +import {Observable} from 'rxjs/Observable'; +import {Url} from 'url'; + + +/** + * The description (metadata) of a collection. This type contains every information the engine + * needs to run. The CollectionMetadataT type parameter contains additional metadata that you + * want to store while remaining type-safe. + */ +export type CollectionDescription = CollectionMetadataT & { + readonly name: string; +}; + +/** + * The description (metadata) of a schematic. This type contains every information the engine + * needs to run. The SchematicMetadataT and CollectionMetadataT type parameters contain additional + * metadata that you want to store while remaining type-safe. + */ +export type SchematicDescription = SchematicMetadataT & { + readonly collection: CollectionDescription; + readonly name: string; +}; + + +/** + * The Host for the Engine. Specifically, the piece of the tooling responsible for resolving + * collections and schematics descriptions. The SchematicMetadataT and CollectionMetadataT type + * parameters contain additional metadata that you want to store while remaining type-safe. + */ +export interface EngineHost { + createCollectionDescription(name: string): CollectionDescription | null; + createSchematicDescription( + name: string, + collection: CollectionDescription): + SchematicDescription | null; + getSchematicRuleFactory( + schematic: SchematicDescription, + collection: CollectionDescription): RuleFactory; + createSourceFromUrl(url: Url): Source | null; + + readonly defaultMergeStrategy?: MergeStrategy; +} + + +/** + * The root Engine for creating and running schematics and collections. Everything related to + * a schematic execution starts from this interface. + * + * CollectionMetadataT is, by default, a generic Collection metadata type. This is used throughout + * the engine typings so that you can use a type that's merged into descriptions, while being + * type-safe. + * + * SchematicMetadataT is a type that contains additional typing for the Schematic Description. + */ +export interface Engine { + createCollection(name: string): Collection; + createSchematic( + name: string, + collection: Collection + ): Schematic; + createSourceFromUrl(url: Url): Source; + + readonly defaultMergeStrategy: MergeStrategy; +} + + +/** + * A Collection as created by the Engine. This should be used by the tool to create schematics, + * or by rules to create other schematics as well. + */ +export interface Collection { + readonly description: CollectionDescription; + + createSchematic(name: string): Schematic; +} + + +/** + * A Schematic as created by the Engine. This should be used by the tool to execute the main + * schematics, or by rules to execute other schematics as well. + */ +export interface Schematic { + readonly description: SchematicDescription; + readonly collection: Collection; + + call(options: T, host: Observable): Observable; +} + + +/** + * A SchematicContext. Contains information necessary for Schematics to execute some rules, for + * example when using another schematics, as we need the engine and collection. + */ +export interface TypedSchematicContext { + readonly engine: Engine; + readonly schematic: Schematic; + readonly host: Observable; + readonly strategy: MergeStrategy; +} + + +/** + * This is used by the Schematics implementations in order to avoid needing to have typing from + * the tooling. Schematics are not specific to a tool. + */ +export type SchematicContext = TypedSchematicContext; + + +/** + * A rule factory, which is normally the way schematics are implemented. Returned by the tooling + * after loading a schematic description. + */ +export type RuleFactory = (options: T) => Rule; + + +/** + * A source is a function that generates a Tree from a specific context. A rule transforms a tree + * into another tree from a specific context. In both cases, an Observable can be returned if + * the source or the rule are asynchronous. Only the last Tree generated in the observable will + * be used though. + * + * We obfuscate the context of Source and Rule because the schematic implementation should not + * know which types is the schematic or collection metadata, as they are both tooling specific. + */ +export type Source = (context: SchematicContext) => Tree | Observable; +export type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable; diff --git a/packages/schematics/src/engine/schematic.ts b/packages/schematics/src/engine/schematic.ts new file mode 100644 index 0000000000..1302b3bd8b --- /dev/null +++ b/packages/schematics/src/engine/schematic.ts @@ -0,0 +1,61 @@ +/** + * @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 { + Collection, + Engine, + RuleFactory, + Schematic, + SchematicDescription, + TypedSchematicContext +} from './interface'; +import {Tree} from '../tree/interface'; +import {BaseException} from '../exception/exception'; + +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/concatMap'; + + +export class InvalidSchematicsNameException extends BaseException { + constructor(name: string) { + super(`Schematics has invalid name: "${name}".`); + } +} + + +export class SchematicImpl implements Schematic { + constructor(private _description: SchematicDescription, + private _factory: RuleFactory, + private _collection: Collection, + private _engine: Engine) { + if (!_description.name.match(/^[-_.a-zA-Z0-9]+$/)) { + throw new InvalidSchematicsNameException(_description.name); + } + } + + get description() { return this._description; } + get collection() { return this._collection; } + + call(options: OptionT, host: Observable): Observable { + let context: TypedSchematicContext = { + engine: this._engine, + schematic: this, + host, + strategy: this._engine.defaultMergeStrategy + }; + + return host.concatMap(tree => { + const result = this._factory(options)(tree, context); + if (result instanceof Observable) { + return result; + } else { + return Observable.of(result); + } + }); + } +} diff --git a/packages/schematics/src/engine/schematic_spec.ts b/packages/schematics/src/engine/schematic_spec.ts new file mode 100644 index 0000000000..ad43a0fdfc --- /dev/null +++ b/packages/schematics/src/engine/schematic_spec.ts @@ -0,0 +1,71 @@ +/** + * @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 {SchematicDescription} from './interface'; +import {SchematicImpl} from './schematic'; +import {MergeStrategy, Tree} from '../tree/interface'; +import {branch, empty} from '../tree/static'; + +import 'rxjs/add/operator/toArray'; +import 'rxjs/add/operator/toPromise'; +import {Observable} from 'rxjs/Observable'; + + +const engine = { + defaultMergeStrategy: MergeStrategy.Default +} as any; + + +describe('Schematic', () => { + it('works with a rule', done => { + let inner: any = null; + const desc: SchematicDescription = { + name: 'test', + description: '', + path: 'a/b/c', + factory: () => (tree: Tree) => { + inner = branch(tree); + tree.create('a/b/c', 'some content'); + return tree; + } + }; + + const schematic = new SchematicImpl(desc, desc.factory, null !, engine); + schematic.call({}, Observable.of(empty())) + .toPromise() + .then(x => { + expect(inner.files).toEqual([]); + expect(x.files).toEqual(['/a/b/c']); + }) + .then(done, done.fail); + }); + + it('works with a rule that returns an observable', done => { + let inner: any = null; + const desc: SchematicDescription = { + name: 'test', + description: '', + path: 'a/b/c', + factory: () => (fem: Tree) => { + inner = fem; + return Observable.of(empty()); + } + }; + + + const schematic = new SchematicImpl(desc, desc.factory, null !, engine); + schematic.call({}, Observable.of(empty())) + .toPromise() + .then(x => { + expect(inner.files).toEqual([]); + expect(x.files).toEqual([]); + expect(inner).not.toBe(x); + }) + .then(done, done.fail); + }); + +}); diff --git a/packages/schematics/src/exception/BUILD b/packages/schematics/src/exception/BUILD new file mode 100644 index 0000000000..5ee82d0ddf --- /dev/null +++ b/packages/schematics/src/exception/BUILD @@ -0,0 +1,10 @@ +package(default_visibility=["//visibility:public"]) +load("@io_bazel_rules_typescript//:defs.bzl", "ts_library") + + +ts_library( + name = "exception", + srcs = glob(["*.ts"]), + deps = [], + tsconfig = "//:tsconfig.json", +) diff --git a/packages/schematics/src/exception/exception.ts b/packages/schematics/src/exception/exception.ts new file mode 100644 index 0000000000..cc82a6b71a --- /dev/null +++ b/packages/schematics/src/exception/exception.ts @@ -0,0 +1,39 @@ +/** + * @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 + */ + +export class BaseException extends Error { + constructor(message = '') { + super(message); + } +} + + +// Exceptions +export class FileDoesNotExistException extends BaseException { + constructor(path: string) { super(`Path "${path}" does not exist.`); } +} +export class FileAlreadyExistException extends BaseException { + constructor(path: string) { super(`Path "${path}" already exist.`); } +} +export class ContentHasMutatedException extends BaseException { + constructor(path: string) { + super(`Content at path "${path}" has changed between the start and the end of an update.`); + } +} +export class InvalidUpdateRecordException extends BaseException { + constructor() { super(`Invalid record instance.`); } +} +export class MergeConflictException extends BaseException { + constructor(path: string) { + super(`A merge conflicted on path "${path}".`); + } +} + +export class UnimplementedException extends BaseException { + constructor() { super('This function is unimplemented.'); } +} diff --git a/packages/schematics/src/index.ts b/packages/schematics/src/index.ts new file mode 100644 index 0000000000..75007477a4 --- /dev/null +++ b/packages/schematics/src/index.ts @@ -0,0 +1,59 @@ +/** + * @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 {FilePredicate, MergeStrategy} from './tree/interface'; +import {Tree as TreeInterface} from './tree/interface'; +import {branch, empty, merge, optimize, partition} from './tree/static'; + + +export { BaseException } from './exception/exception'; + +export * from './tree/action'; +export * from './engine/collection'; +export * from './engine/engine'; +export * from './engine/interface'; +export * from './tree/interface'; +export * from './rules/base'; +export * from './rules/move'; +export * from './rules/random'; +export * from './rules/schematic'; +export * from './rules/template'; +export * from './rules/url'; +export * from './tree/empty'; +export * from './tree/filesystem'; +export * from './tree/memory-host'; +export {UpdateRecorder} from './tree/interface'; +export * from './engine/schematic'; +export * from './sink/dryrun'; +export {FileSystemSink} from './sink/filesystem'; +export * from './utility/path'; + + +export interface TreeConstructor { + new (): TreeInterface; + + empty(): TreeInterface; + branch(tree: TreeInterface): TreeInterface; + merge(tree: TreeInterface, other: TreeInterface, strategy?: MergeStrategy): TreeInterface; + partition(tree: TreeInterface, predicate: FilePredicate): [TreeInterface, TreeInterface]; + optimize(tree: TreeInterface): TreeInterface; +} + +export type Tree = TreeInterface; +export const Tree: TreeConstructor = { + empty() { return empty(); }, + branch(tree: TreeInterface) { return branch(tree); }, + merge(tree: TreeInterface, + other: TreeInterface, + strategy: MergeStrategy = MergeStrategy.Default) { + return merge(tree, other, strategy); + }, + partition(tree: TreeInterface, predicate: FilePredicate) { + return partition(tree, predicate); + }, + optimize(tree: TreeInterface) { return optimize(tree); } +} as any; diff --git a/packages/schematics/src/rules/BUILD b/packages/schematics/src/rules/BUILD new file mode 100644 index 0000000000..ff06e64d09 --- /dev/null +++ b/packages/schematics/src/rules/BUILD @@ -0,0 +1,14 @@ +package(default_visibility=["//visibility:public"]) +load("@io_bazel_rules_typescript//:defs.bzl", "ts_library") + + +ts_library( + name = "rules", + srcs = glob(["*.ts", "*/*.ts"]), + deps = [ + "//packages/schematics/src/engine", + "//packages/schematics/src/exception", + "//packages/schematics/src/tree", + ], + tsconfig = "//:tsconfig.json", +) diff --git a/packages/schematics/src/rules/base.ts b/packages/schematics/src/rules/base.ts new file mode 100644 index 0000000000..646542d3e0 --- /dev/null +++ b/packages/schematics/src/rules/base.ts @@ -0,0 +1,94 @@ +/** + * @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 {callRule, callSource} from './call'; +import {MergeStrategy, FilePredicate} from '../tree/interface'; +import {Rule, SchematicContext, Source} from '../engine/interface'; +import {VirtualTree} from '../tree/virtual'; +import {FilteredTree} from '../tree/filtered'; +import {Tree} from '../tree/interface'; +import {empty as staticEmpty} from '../tree/static'; + +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/mergeMap'; + + +/** + * A Source that returns an tree as its single value. + */ +export function source(tree: Tree): Source { + return () => tree; +} + + +/** + * A source that returns an empty tree. + */ +export function empty(): Source { + return () => staticEmpty(); +} + + +/** + * Chain multiple rules into a single rule. + */ +export function chain(rules: Rule[]): Rule { + return (tree: Tree, context: SchematicContext) => { + return rules.reduce((acc: Observable, curr: Rule) => { + return callRule(curr, acc, context); + }, Observable.of(tree)); + }; +} + + +/** + * Apply multiple rules to a source, and returns the source transformed. + */ +export function apply(source: Source, rules: Rule[]): Source { + return (context: SchematicContext) => { + return callRule(chain(rules), callSource(source, context), context); + }; +} + + +/** + * Merge multiple sources' output. + */ +export function mergeSources(sources: Source[], + strategy: MergeStrategy = MergeStrategy.Default): Source { + return apply(empty(), [merge(sources, strategy)]); +} + + +/** + * + * @param sources + * @param strategy + * @return {(tree:Tree, context:SchematicContext)=>Observable} + */ +export function merge(sources: Source[], strategy: MergeStrategy = MergeStrategy.Default): Rule { + return (tree: Tree, context: SchematicContext) => { + return sources.reduce((acc: Observable, curr: Source) => { + const result = callSource(curr, context); + return acc.concatMap(x => { + return result.map(y => VirtualTree.merge(x, y, strategy || context.strategy)); + }); + }, Observable.of(tree)); + }; +} + + +export function noop(): Rule { + return (tree: Tree, _context: SchematicContext) => tree; +} + + +export function filter(predicate: FilePredicate): Rule { + return (tree: Tree) => new FilteredTree(tree, predicate); +} diff --git a/packages/schematics/src/rules/base_spec.ts b/packages/schematics/src/rules/base_spec.ts new file mode 100644 index 0000000000..3a692f3130 --- /dev/null +++ b/packages/schematics/src/rules/base_spec.ts @@ -0,0 +1,124 @@ +/** + * @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 {apply, chain} from './base'; +import {callRule, callSource} from './call'; +import {Rule, SchematicContext, Source} from '../engine/interface'; +import {Tree} from '../tree/interface'; +import {empty} from '../tree/static'; + +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/operator/toPromise'; + + + +const context: SchematicContext = null !; + + +describe('chain', () => { + it('works with simple rules', done => { + const rulesCalled: Tree[] = []; + + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const rule0: Rule = (tree: Tree) => { rulesCalled[0] = tree; return tree1; }; + const rule1: Rule = (tree: Tree) => { rulesCalled[1] = tree; return tree2; }; + const rule2: Rule = (tree: Tree) => { rulesCalled[2] = tree; return tree3; }; + + callRule(chain([ rule0, rule1, rule2 ]), Observable.of(tree0), context) + .toPromise() + .then(result => { + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }) + .then(done, done.fail); + }); + + it('works with observable rules', done => { + const rulesCalled: Tree[] = []; + + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const rule0: Rule = (tree: Tree) => { rulesCalled[0] = tree; return Observable.of(tree1); }; + const rule1: Rule = (tree: Tree) => { rulesCalled[1] = tree; return Observable.of(tree2); }; + const rule2: Rule = (tree: Tree) => { rulesCalled[2] = tree; return tree3; }; + + callRule(chain([ rule0, rule1, rule2 ]), Observable.of(tree0), context) + .toPromise() + .then(result => { + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }) + .then(done, done.fail); + }); +}); + +describe('apply', () => { + it('works with simple rules', done => { + const rulesCalled: Tree[] = []; + let sourceCalled = false; + + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const source: Source = () => { sourceCalled = true; return tree0; }; + const rule0: Rule = (tree: Tree) => { rulesCalled[0] = tree; return tree1; }; + const rule1: Rule = (tree: Tree) => { rulesCalled[1] = tree; return tree2; }; + const rule2: Rule = (tree: Tree) => { rulesCalled[2] = tree; return tree3; }; + + callSource(apply(source, [ rule0, rule1, rule2 ]), context) + .toPromise() + .then(result => { + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }) + .then(done, done.fail); + }); + + it('works with observable rules', done => { + const rulesCalled: Tree[] = []; + let sourceCalled = false; + + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const source: Source = () => { sourceCalled = true; return tree0; }; + const rule0: Rule = (tree: Tree) => { rulesCalled[0] = tree; return Observable.of(tree1); }; + const rule1: Rule = (tree: Tree) => { rulesCalled[1] = tree; return Observable.of(tree2); }; + const rule2: Rule = (tree: Tree) => { rulesCalled[2] = tree; return tree3; }; + + callSource(apply(source, [ rule0, rule1, rule2 ]), context) + .toPromise() + .then(result => { + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }) + .then(done, done.fail); + }); +}); diff --git a/packages/schematics/src/rules/call.ts b/packages/schematics/src/rules/call.ts new file mode 100644 index 0000000000..ee5ce78d1a --- /dev/null +++ b/packages/schematics/src/rules/call.ts @@ -0,0 +1,66 @@ +import {BaseException} from '../exception/exception'; +import {Rule, SchematicContext, Source} from '../engine/interface'; +import {Tree} from '../tree/interface'; +import {VirtualTree} from '../tree/virtual'; + +import {Observable} from 'rxjs/Observable'; + + +/** + * When a rule or source returns an invalid value. + */ +export class InvalidRuleResultException extends BaseException { + constructor(value: any) { + let v = 'Unknown Type'; + if (value === undefined) { + v = 'undefined'; + } else if (value === null) { + v = 'null'; + } else if (typeof value == 'function') { + v = `Function()`; + } else if (typeof value != 'object') { + v = `${typeof value}(${JSON.stringify(value)})`; + } else { + if (Object.getPrototypeOf(value) == Object) { + v = `Object(${JSON.stringify(value)})`; + } else if (value.constructor) { + v = `Instance of class ${value.constructor.name}`; + } else { + v = 'Unknown Object'; + } + } + super(`Invalid rule or source result: ${v}.`); + } +} + + +export function callSource(source: Source, context: SchematicContext): Observable { + const result = source(context); + + if (result instanceof VirtualTree) { + return Observable.of(result); + } else if (result instanceof Observable) { + return result; + } else { + throw new InvalidRuleResultException(result); + } +} + + +export function callRule(rule: Rule, + input: Observable, + context: SchematicContext): Observable { + return input.mergeMap(i => { + const result = rule(i, context); + + if (result instanceof VirtualTree) { + return Observable.of(result as Tree); + } else if (result instanceof Observable) { + return result; + } else { + throw new InvalidRuleResultException(result); + } + }); +} + + diff --git a/packages/schematics/src/rules/move.ts b/packages/schematics/src/rules/move.ts new file mode 100644 index 0000000000..d9712bbe3e --- /dev/null +++ b/packages/schematics/src/rules/move.ts @@ -0,0 +1,17 @@ +/** + * @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 {Rule} from '../engine/interface'; +import {Tree} from '../tree/interface'; + + +export function move(root: string): Rule { + return (tree: Tree) => { + tree.files.forEach(originalPath => tree.rename(originalPath, `${root}/${originalPath}`)); + return tree; + }; +} diff --git a/packages/schematics/src/rules/random.ts b/packages/schematics/src/rules/random.ts new file mode 100644 index 0000000000..55d5bbeefd --- /dev/null +++ b/packages/schematics/src/rules/random.ts @@ -0,0 +1,50 @@ +/** + * @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 {Source} from '../engine/interface'; +import {VirtualTree} from '../tree/virtual'; + + +function generateStringOfLength(l: number) { + return new Array(l).fill(0).map(_x => { + return 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; + }).join(''); +} + + +function random(from: number, to: number) { + return Math.floor(Math.random() * (to - from)) + from; +} + + +export interface RandomOptions { + root?: string; + multi?: boolean | number; + multiFiles?: boolean | number; +} + + +export default function(options: RandomOptions): Source { + return () => { + const root = ('root' in options) ? options.root : '/'; + + const map = new VirtualTree(); + const nbFiles = ('multiFiles' in options) + ? (typeof options.multiFiles == 'number' ? options.multiFiles : random(2, 12)) + : 1; + + for (let i = 0; i < nbFiles; i++) { + const path = 'a/b/c/d/e/f'.slice(Math.random() * 10); + const fileName = generateStringOfLength(20); + const content = generateStringOfLength(100); + + map.create(root + '/' + path + '/' + fileName, content); + } + + return map; + }; +} diff --git a/packages/schematics/src/rules/schematic.ts b/packages/schematics/src/rules/schematic.ts new file mode 100644 index 0000000000..38d0e6c8fc --- /dev/null +++ b/packages/schematics/src/rules/schematic.ts @@ -0,0 +1,46 @@ +/** + * @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 {Rule, SchematicContext} from '../engine/interface'; +import {Tree} from '../tree/interface'; +import {branch} from '../tree/static'; + +import {Observable} from 'rxjs/Observable'; + + +/** + * Run a schematic from a separate collection. + * + * @param collectionName The name of the collection that contains the schematic to run. + * @param schematicName The name of the schematic to run. + * @param options The options to pass as input to the RuleFactory. + */ +export function externalSchematic(collectionName: string, + schematicName: string, + options: T): Rule { + return (host: Tree, context: SchematicContext) => { + const collection = context.engine.createCollection(collectionName); + const schematic = collection.createSchematic(schematicName); + return schematic.call(options, Observable.of(branch(host))); + }; +} + + +/** + * Run a schematic from the same collection. + * + * @param schematicName The name of the schematic to run. + * @param options The options to pass as input to the RuleFactory. + */ +export function schematic(schematicName: string, options: T): Rule { + return (host: Tree, context: SchematicContext) => { + let collection = context.schematic.collection; + + const schematic = collection.createSchematic(schematicName); + return schematic.call(options, Observable.of(branch(host))); + }; +} diff --git a/packages/schematics/src/rules/template.ts b/packages/schematics/src/rules/template.ts new file mode 100644 index 0000000000..a33b756323 --- /dev/null +++ b/packages/schematics/src/rules/template.ts @@ -0,0 +1,113 @@ +/** + * @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 {chain} from './base'; +import {template as templateImpl} from './template/template'; +import {isBinary} from './utils/is-binary'; +import {Rule} from '../engine/interface'; +import {BaseException} from '../exception/exception'; +import {Tree} from '../tree/interface'; + + + +export class OptionIsNotDefinedException extends BaseException { + constructor(name: string) { super(`Option "${name}" is not defined.`); } +} + + +export class UnknownPipeException extends BaseException { + constructor(name: string) { super(`Pipe "${name}" is not defined.`); } +} + + +export class InvalidPipeException extends BaseException { + constructor(name: string) { super(`Pipe "${name}" is invalid.`); } +} + + +export const kPathTemplateComponentRE = /__([^_]+)__/g; +export const kPathTemplatePipeRE = /@([^@]+)/; + + + +export function applyContentTemplate(original: string, + options: T): string { + return templateImpl(original, {})(options); +} + + +export function contentTemplate(options: T): Rule { + return (tree: Tree) => { + tree.files.forEach(originalPath => { + // Lodash Template. + const content = tree.read(originalPath); + + if (content && !isBinary(content)) { + const output = applyContentTemplate(content.toString('utf-8'), options); + tree.overwrite(originalPath, output); + } + }); + + return tree; + }; +} + + +export function applyPathTemplate(original: string, + options: T): string { + // Path template. + return original.replace(kPathTemplateComponentRE, (_, match) => { + const [name, ...pipes] = match.split(kPathTemplatePipeRE); + const value = typeof options[name] == 'function' + ? options[name].call(options, original) + : options[name]; + + if (value === undefined) { + throw new OptionIsNotDefinedException(name); + } + + return pipes.reduce((acc: string, pipe: string) => { + if (!pipe) { + return acc; + } + if (!(pipe in options)) { + throw new UnknownPipeException(pipe); + } + if (typeof options[pipe] != 'function') { + throw new InvalidPipeException(pipe); + } + + // Coerce to string. + return '' + (options[pipe])(acc); + }, '' + value); + }); +} + + +export function pathTemplate(options: T): Rule { + return (tree: Tree) => { + tree.files.forEach(originalPath => { + const newPath = applyPathTemplate(originalPath, options); + if (originalPath === newPath) { + return; + } + + tree.rename(originalPath, newPath); + }); + + return tree; + }; +} + + + +export function template(options: T): Rule { + return chain([ + contentTemplate(options), + pathTemplate(options) + ]); +} diff --git a/packages/schematics/src/rules/template/template.ts b/packages/schematics/src/rules/template/template.ts new file mode 100644 index 0000000000..b09f70ca69 --- /dev/null +++ b/packages/schematics/src/rules/template/template.ts @@ -0,0 +1,135 @@ +/** + * @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 + */ + +// Matches <%= expr %>. This does not support structural JavaScript (for/if/...). +const kInterpolateRe = /<%=([\s\S]+?)%>/g; + +// Used to match template delimiters. +// <%- expr %>: HTML escape the value. +// <% ... %>: Structural template code. +const kEscapeRe = /<%-([\s\S]+?)%>/g; +const kEvaluateRe = /<%([\s\S]+?)%>/g; + +/** Used to map characters to HTML entities. */ +const kHtmlEscapes: {[char: string]: string} = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' +}; + +// Used to match HTML entities and HTML characters. +const reUnescapedHtml = new RegExp(`[${Object.keys(kHtmlEscapes).join('')}]`, 'g'); + +// Options to pass to template. +export interface TemplateOptions { + sourceURL?: string; +} + + +// Used to match empty string literals in compiled template source. +const reEmptyStringLeading = /\b__p \+= '';/g; +const reEmptyStringMiddle = /\b(__p \+=) '' \+/g; +const reEmptyStringTrailing = /(__e\(.*?\)|\b__t\)) \+\n'';/g; + + +// Used to escape characters for inclusion in compiled string literals. +const stringEscapes: {[char: string]: string} = { + '\\': '\\\\', + "'": "\\'", + '\n': '\\n', + '\r': '\\r', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +}; + +// Used to match unescaped characters in compiled string literals. +const reUnescapedString = /['\n\r\u2028\u2029\\]/g; + + +function _escape(s: string) { + return s ? s.replace(reUnescapedHtml, key => kHtmlEscapes[key]) : ''; +} + + +/** + * An equivalent of lodash templates, which is based on John Resig's `tmpl` implementation + * (http://ejohn.org/blog/javascript-micro-templating/) and Laura Doktorova's doT.js + * (https://github.com/olado/doT). + * + * This version differs from lodash by removing support from ES6 quasi-literals, and making the + * code slightly simpler to follow. It also does not depend on any third party, which is nice. + * + * @param content + * @param options + * @return {any} + */ +export function template(content: string, options: TemplateOptions): (input: T) => string { + const interpolate = kInterpolateRe; + let isEvaluating; + let index = 0; + let source = `__p += '`; + + // Compile the regexp to match each delimiter. + const reDelimiters = RegExp( + `${kEscapeRe.source}|${interpolate.source}|${kEvaluateRe.source}|$`, 'g'); + + // Use a sourceURL for easier debugging. + const sourceURL = options.sourceURL ? '//# sourceURL=' + options.sourceURL + '\n' : ''; + + content.replace(reDelimiters, (match, escapeValue, interpolateValue, evaluateValue, offset) => { + // Escape characters that can't be included in string literals. + source += content.slice(index, offset).replace(reUnescapedString, chr => stringEscapes[chr]); + + // Replace delimiters with snippets. + if (escapeValue) { + source += `' +\n__e(${escapeValue}) +\n '`; + } + if (evaluateValue) { + isEvaluating = true; + source += `';\n${evaluateValue};\n__p += '`; + } + if (interpolateValue) { + source += `' +\n((__t = (${interpolateValue})) == null ? '' : __t) +\n '`; + } + index = offset + match.length; + + return match; + }); + + source += "';\n"; + + // Cleanup code by stripping empty strings. + source = (isEvaluating ? source.replace(reEmptyStringLeading, '') : source) + .replace(reEmptyStringMiddle, '$1') + .replace(reEmptyStringTrailing, '$1;'); + + // Frame code as the function body. + source = ` + return function(obj) { + obj || (obj = {}); + let __t; + let __p = ''; + const __e = _.escape; + with (obj) { + ${source.replace(/\n/g, '\n ')} + } + return __p; + }; + `; + + const fn = Function('_', sourceURL + source); + const result = fn({ escape: _escape }); + + // Provide the compiled function's source by its `toString` method or + // the `source` property as a convenience for inlining compiled templates. + result.source = source; + return result; +} diff --git a/packages/schematics/src/rules/template_spec.ts b/packages/schematics/src/rules/template_spec.ts new file mode 100644 index 0000000000..9e70d4fc63 --- /dev/null +++ b/packages/schematics/src/rules/template_spec.ts @@ -0,0 +1,111 @@ +/** + * @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 { + applyContentTemplate, + applyPathTemplate, + InvalidPipeException, + OptionIsNotDefinedException, + UnknownPipeException +} from './template'; + + +describe('applyPathTemplate', () => { + it('works', () => { + expect(applyPathTemplate('a/b/c/d', {})).toBe('a/b/c/d'); + expect(applyPathTemplate('a/b/__c__/d', { c: 1 })).toBe('a/b/1/d'); + expect(applyPathTemplate('a/b/__c__/d', { c: 'hello/world' })).toBe('a/b/hello/world/d'); + expect(applyPathTemplate('a__c__b', { c: 'hello/world' })).toBe('ahello/worldb'); + expect(applyPathTemplate('a__c__b__d__c', { c: '1', d: '2' })).toBe('a1b2c'); + }); + + it('works with functions', () => { + let arg = ''; + expect(applyPathTemplate('a__c__b', { + c: (x: string) => { + arg = x; + return 'hello'; + } + })).toBe('ahellob'); + expect(arg).toBe('a__c__b'); + }); + + it('works with pipes', () => { + let called = ''; + let called2 = ''; + + expect(applyPathTemplate('a__c@d__b', { + c: 1, + d: (x: string) => { + called = x; + return 2; + } + })).toBe('a2b'); + expect(called).toBe('1'); + + expect(applyPathTemplate('a__c@d@e__b', { + c: 10, + d: (x: string) => { + called = x; + return 20; + }, + e: (x: string) => { + called2 = x; + return 30; + } + })).toBe('a30b'); + expect(called).toBe('10'); + expect(called2).toBe('20'); + }); + + it('errors out on undefined values', () => { + expect(() => applyPathTemplate('a__b__c', {})).toThrow(new OptionIsNotDefinedException('b')); + }); + + it('errors out on undefined or invalid pipes', () => { + expect(() => applyPathTemplate('a__b@d__c', { b: 1 })).toThrow(new UnknownPipeException('d')); + expect(() => applyPathTemplate('a__b@d__c', { b: 1, d: 1 })) + .toThrow(new InvalidPipeException('d')); + }); +}); + + + +describe('contentTemplate', () => { + it('works with echo token <%= ... %>', () => { + expect(applyContentTemplate('a<%= value %>b', { value: 123 })).toBe('a123b'); + }); + + it('works with if', () => { + expect(applyContentTemplate('a<% if (a) { %>b<% } %>c', { + value: 123, + a: true + })).toBe('abc'); + expect(applyContentTemplate('a<% if (a) { %>b<% } %>c', { + value: 123, + a: false + })).toBe('ac'); + }); + + it('works with for', () => { + expect(applyContentTemplate('a<% for (let i = 0; i < value; i++) { %>1<% } %>b', { + value: 5 + })).toBe('a11111b'); + }); + + it('escapes HTML', () => { + expect(applyContentTemplate('a<%- html %>b', { + html: '