feat(@angular-devkit/schematics): add .template as an extension

New rules to deal with templates using a .template extension. Apply the template only
to those files, then remove the .template suffix.

Also added a new rename() rule that takes a matcher and a renamer. Nothing big there.

Also added a new composeFileOperator() that compose operators one after the other.
This commit is contained in:
Hans Larsen 2018-07-20 11:32:20 -07:00 committed by Alex Eagle
parent 9aadb8e6d1
commit f3e389bd8c
6 changed files with 182 additions and 2 deletions

View File

@ -99,6 +99,7 @@ ts_library(
),
deps = [
":schematics",
":testing",
"//packages/angular_devkit/core",
"@rxjs",
"@rxjs//operators",

View File

@ -185,3 +185,20 @@ export function forEach(operator: FileOperator): Rule {
return tree;
};
}
export function composeFileOperators(operators: FileOperator[]): FileOperator {
return (entry: FileEntry) => {
let current: FileEntry | null = entry;
for (const op of operators) {
current = op(current);
if (current === null) {
// Deleted, just return.
return null;
}
}
return current;
};
}

View File

@ -0,0 +1,25 @@
/**
* @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 { normalize } from '@angular-devkit/core';
import { Rule } from '../engine/interface';
import { FilePredicate } from '../tree/interface';
import { forEach } from './base';
export function rename(match: FilePredicate<boolean>, to: FilePredicate<string>): Rule {
return forEach(entry => {
if (match(entry.path, entry)) {
return {
content: entry.content,
path: normalize(to(entry.path, entry)),
};
} else {
return entry;
}
});
}

View File

@ -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
*/
// tslint:disable:non-null-operator
import { of as observableOf } from 'rxjs';
import { SchematicContext } from '../engine/interface';
import { HostTree } from '../tree/host-tree';
import { callRule } from './call';
import { rename } from './rename';
const context: SchematicContext = null !;
describe('rename', () => {
it('works', done => {
const tree = new HostTree();
tree.create('a/b/file1', 'hello world');
tree.create('a/b/file2', 'hello world');
tree.create('a/c/file3', 'hello world');
let i = 0;
// Rename all files that contain 'b' to 'hello'.
callRule(rename(x => !!x.match(/b/), () => 'hello' + (i++)), observableOf(tree), context)
.toPromise()
.then(result => {
expect(result.exists('a/b/file1')).toBe(false);
expect(result.exists('a/b/file2')).toBe(false);
expect(result.exists('hello0')).toBe(true);
expect(result.exists('hello1')).toBe(true);
expect(result.exists('a/c/file3')).toBe(true);
})
.then(done, done.fail);
});
it('works (2)', done => {
const tree = new HostTree();
tree.create('a/b/file1', 'hello world');
tree.create('a/b/file2', 'hello world');
tree.create('a/c/file3', 'hello world');
let i = 0;
// Rename all files that contain 'b' to 'hello'.
callRule(rename(x => !!x.match(/b/), x => x + (i++)), observableOf(tree), context)
.toPromise()
.then(result => {
expect(result.exists('a/b/file1')).toBe(false);
expect(result.exists('a/b/file2')).toBe(false);
expect(result.exists('a/b/file10')).toBe(true);
expect(result.exists('a/b/file21')).toBe(true);
expect(result.exists('a/c/file3')).toBe(true);
})
.then(done, done.fail);
});
});

View File

@ -8,10 +8,14 @@
import { BaseException, normalize, template as templateImpl } from '@angular-devkit/core';
import { FileOperator, Rule } from '../engine/interface';
import { FileEntry } from '../tree/interface';
import { chain, forEach } from './base';
import { chain, composeFileOperators, forEach, when } from './base';
import { rename } from './rename';
import { isBinary } from './utils/is-binary';
export const TEMPLATE_FILENAME_RE = /\.template$/;
export class OptionIsNotDefinedException extends BaseException {
constructor(name: string) { super(`Option "${name}" is not defined.`); }
}
@ -144,6 +148,17 @@ export function pathTemplate<T extends PathTemplateData>(options: T): Rule {
}
/**
* Remove every `.template` suffix from file names.
*/
export function renameTemplateFiles(): Rule {
return rename(
path => !!path.match(TEMPLATE_FILENAME_RE),
path => path.replace(TEMPLATE_FILENAME_RE, ''),
);
}
export function template<T>(options: T): Rule {
return chain([
contentTemplate(options),
@ -153,3 +168,23 @@ export function template<T>(options: T): Rule {
pathTemplate(options as {} as PathTemplateData),
]);
}
export function applyTemplates<T>(options: T): Rule {
return forEach(
when(
path => path.endsWith('.template'),
composeFileOperators([
applyContentTemplate(options),
// See above for this weird cast.
applyPathTemplate(options as {} as PathTemplateData),
entry => {
return {
content: entry.content,
path: entry.path.replace(TEMPLATE_FILENAME_RE, ''),
} as FileEntry;
},
]),
),
);
}

View File

@ -5,14 +5,21 @@
* 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
*/
// tslint:disable:no-implicit-dependencies
import { normalize } from '@angular-devkit/core';
import { FileEntry } from '../tree/interface';
import { UnitTestTree } from '@angular-devkit/schematics/testing';
import { of as observableOf } from 'rxjs';
import { SchematicContext } from '../engine/interface';
import { HostTree } from '../tree/host-tree';
import { FileEntry, MergeStrategy } from '../tree/interface';
import { callRule } from './call';
import {
InvalidPipeException,
OptionIsNotDefinedException,
UnknownPipeException,
applyContentTemplate,
applyPathTemplate,
applyTemplates,
} from './template';
@ -151,3 +158,37 @@ describe('contentTemplate', () => {
expect(_applyContentTemplate('a<%= "\\n" + "b" %>b', {})).toBe('a\nbb');
});
});
describe('applyTemplateFiles', () => {
it('works with template files exclusively', done => {
const tree = new UnitTestTree(new HostTree());
tree.create('a/b/file1', 'hello world');
tree.create('a/b/file2', 'hello world');
tree.create('a/b/file3.template', 'hello <%= 1 %> world');
tree.create('a/b/file__a__.template', 'hello <%= 1 %> world');
tree.create('a/b/file__norename__', 'hello <%= 1 %> world');
tree.create('a/c/file4', 'hello world');
const context: SchematicContext = {
strategy: MergeStrategy.Default,
} as SchematicContext;
// Rename all files that contain 'b' to 'hello'.
callRule(applyTemplates({ a: 'foo' }), observableOf(tree), context)
.toPromise()
.then(() => {
expect([...tree.files].sort()).toEqual([
'/a/b/file1',
'/a/b/file2',
'/a/b/file3',
'/a/b/file__norename__',
'/a/b/filefoo',
'/a/c/file4',
]);
expect(tree.readContent('/a/b/file3')).toBe('hello 1 world');
})
.then(done, done.fail);
});
});