diff --git a/.gitignore b/.gitignore index 1d9e494ec3..b8e34cb838 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,9 @@ publish/ integration/**/lib/ integration/**/*.ngfactory.ts integration/**/*.ngsummary.json - +schematics/demo +schematics/utils/custom-theme.ts +schematics/utils/lib-versions.ts # dependencies node_modules diff --git a/build.sh b/build.sh index c270038a9c..772c267ed2 100755 --- a/build.sh +++ b/build.sh @@ -45,6 +45,16 @@ sed -e "s/from '.\//from '.\/src\//g" publish/src/index.d.ts > publish/antd.d.ts sed -e "s/\":\".\//\":\".\/src\//g" publish/src/index.metadata.json > publish/antd.metadata.json rm publish/src/index.d.ts publish/src/index.metadata.json +echo 'Generate schematics by demos' +npm run schematic:demo + +echo 'Building schematics' +node ./schematics_script/set-version.js +node ./schematics_script/set-theme.js +npm run schematic:demo +npm run schematic:build +rm -rf schematics/demo + echo 'Copying package.json' cp package.json publish/package.json diff --git a/package.json b/package.json index 7d3a92eeb2..ae66e5245b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "site:start": "node site_scripts/generate-site init && node site_scripts/generateColorLess && ng serve --port 0 --open", "site:init": "node site_scripts/generate-site init && node site_scripts/generateColorLess", "site": "node site_scripts/generate-site", + "schematic:demo": "node schematics_script/demo2schematics", + "schematic:tsc": "tsc -p schematics/tsconfig.json", + "schematic:build": "npm run schematic:tsc && node schematics_script/copy-resources", "ng": "ng", "start": "ng serve -p 0", "build": "node site_scripts/generate-site init && ng build", @@ -25,6 +28,7 @@ "module": "./esm5/antd.js", "es2015": "./esm2015/antd.js", "typings": "./antd.d.ts", + "schematics": "./schematics/collection.json", "keywords": [ "ant", "design", @@ -61,6 +65,9 @@ "zone.js": "^0.8.26", "@angular/compiler-cli": "^6.0.0", "@angular-devkit/build-angular": "~0.6.0", + "@angular-devkit/core": "^0.6.0", + "@angular-devkit/schematics": "^0.6.0", + "@schematics/angular": "^0.6.0", "typescript": "~2.7.2", "@angular/cli": "~6.0.0", "@angular/language-service": "^6.0.0", @@ -96,7 +103,9 @@ "@stackblitz/sdk": "^1.1.1", "codecov": "^3.0.0", "ngx-infinite-scroll": "^6.0.0", - "less-plugin-clean-css": "^1.5.1" + "less-plugin-clean-css": "^1.5.1", + "fs-extra": "^6.0.1", + "parse5": "^4.0.0" }, "peerDependencies": { "@angular/animations": "^6.0.0", diff --git a/schematics/README.md b/schematics/README.md new file mode 100644 index 0000000000..8929816716 --- /dev/null +++ b/schematics/README.md @@ -0,0 +1,45 @@ +# ng-zorro-antd Schematics + +## Schematics + +### ng-add + +添加 ng-zorro-antd 与它的依赖,并根据需要自动配置。 + +- 添加 ng-zorro-antd 到 `package.json` +- 替换 `app.component.html` 引导内容 +- 在根模块导入必要的模块 +- 进行国际化配置 +- 将用于自定义的 `theme.less` 或编译后的 css 导入 `angular.json` + +```bash +$ ng add ng-zorro-antd [--locale=zh-CN] [--theme] [--skipPackageJson] +``` + +## 开发 + +### 脚本 + +- `npm run schematic:build` 编译到 publish 文件夹 +- `npm run schematic:demo` 从 demo 生成 schematics +- `node ./schematics_script/set-version.js` 从 package.json 设置版本号 +- `node ./schematics_script/set-theme.js` 从 site_scripts/_site/src/theme.less 设置自定义样式内容 + +### 开发 + +只有首次运行才需要以下步骤。 + +1. 运行 `npm run generate` 生成 `publish` 文件夹 +2. `cd publish && npm link` +3. `ng new schematic-debug` +4. `cd schematic-debug && npm link ng-zorro-antd` + +调试 + +1. `cd schematic-debug` +1. `git checkout . && git clean -fd` +1. `ng g ng-zorro-antd:[schematics]` + +发布 + +原有发布流程不变,但是 `schematics/utils/custom-theme.ts` 和 `schematics/utils/lib-versions.ts` 内容为动态生成,不提交到版本管理。 \ No newline at end of file diff --git a/schematics/collection.json b/schematics/collection.json new file mode 100644 index 0000000000..60cc27039f --- /dev/null +++ b/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "add NG-ZORRO", + "factory": "./ng-add", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/schematics/ng-add/index.ts b/schematics/ng-add/index.ts new file mode 100644 index 0000000000..9b03be7c2d --- /dev/null +++ b/schematics/ng-add/index.ts @@ -0,0 +1,176 @@ +import { chain, noop, Rule, SchematicsException, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import * as ts from 'typescript'; +import { addModuleImportToRootModule, getSourceFile } from '../utils/ast'; +import { createCustomTheme } from '../utils/custom-theme'; +import { addSymbolToNgModuleMetadata, findNodes, insertAfterLastOccurrence } from '../utils/devkit-utils/ast-utils'; +import { InsertChange } from '../utils/devkit-utils/change'; +import { getProjectFromWorkspace, getWorkspace, Project, Workspace } from '../utils/devkit-utils/config'; +import { getAppModulePath } from '../utils/devkit-utils/ng-ast-utils'; +import { insertImport } from '../utils/devkit-utils/route-utils'; +import { zorroVersion } from '../utils/lib-versions'; +import { addPackageToPackageJson } from '../utils/package'; +import { Schema } from './schema'; + +export default function (options: Schema): Rule { + return chain([ + options && options.skipPackageJson ? noop() : addZorroToPackageJson(), + options && options.theme ? downgradeLess() : noop(), + setBootstrapPage(), + addThemeToAppStyles(options), + addModulesToAppModule(options), + addI18n(options), + (options && !options.skipPackageJson) || (options && !options.theme) ? installNodeDeps() : noop() + ]); +} + +/** 添加 i18n 配置, 取决于 options.locale */ +function addI18n(options: Schema): (host: Tree) => Tree { + return function (host: Tree): Tree { + const workspace = getWorkspace(host); + const project = getProjectFromWorkspace(workspace, options.project); + const modulePath = getAppModulePath(host, project.architect.build.options.main); + const moduleSource = getSourceFile(host, modulePath); + const locale = options.locale; + const localePrefix = locale.split('_')[0]; + + if (!moduleSource) { + throw new SchematicsException(`Module not found: ${modulePath}`); + } + + if (!locale) { + throw new SchematicsException(`Invalid locale-symbol`); + } + + const allImports = findNodes(moduleSource, ts.SyntaxKind.ImportDeclaration); + + const changes = [ + insertImport(moduleSource, modulePath, 'NZ_I18N', 'ng-zorro-antd'), + insertImport(moduleSource, modulePath, locale, 'ng-zorro-antd'), + insertImport(moduleSource, modulePath, 'registerLocaleData', '@angular/common'), + insertImport(moduleSource, modulePath, localePrefix, `@angular/common/locales/${localePrefix}`, true), + ...addSymbolToNgModuleMetadata(moduleSource, modulePath, 'providers', `{ provide: NZ_I18N, useValue: ${locale} }`, null), + insertAfterLastOccurrence(allImports, `\n\nregisterLocaleData(${localePrefix});`, modulePath, 0) + ]; + + const recorder = host.beginUpdate(modulePath); + changes.forEach((change) => { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + }); + + host.commitUpdate(recorder); + return host; + }; +} + +/** 降级 less */ +function downgradeLess(): (host: Tree) => Tree { + return (host: Tree) => { + addPackageToPackageJson(host, 'dependencies', 'less', '^2.7.3'); + return host; + }; +} + +/** 添加 ng-zorro-antd 到 package.json 的 dependencies */ +function addZorroToPackageJson(): (host: Tree) => Tree { + return (host: Tree) => { + addPackageToPackageJson(host, 'dependencies', 'ng-zorro-antd', zorroVersion); + return host; + }; +} + +/** 添加 BrowserAnimationsModule FormsModule HttpClientModule NgZorroAntdModule 到 app.module */ +function addModulesToAppModule(options: Schema): (host: Tree) => Tree { + return (host: Tree) => { + const workspace = getWorkspace(host); + const project = getProjectFromWorkspace(workspace, options.project); + + addModuleImportToRootModule(host, 'BrowserAnimationsModule', '@angular/platform-browser/animations', project); + addModuleImportToRootModule(host, 'FormsModule', '@angular/forms', project); + addModuleImportToRootModule(host, 'HttpClientModule', '@angular/common/http', project); + addModuleImportToRootModule(host, 'NgZorroAntdModule.forRoot()', 'ng-zorro-antd', project); + + return host; + }; +} + +/** 添加样式配置 */ +export function addThemeToAppStyles(options: Schema): (host: Tree) => Tree { + return function (host: Tree): Tree { + const workspace = getWorkspace(host); + const project = getProjectFromWorkspace(workspace, options.project); + if (options.theme) { + insertCustomTheme(project, host, workspace); + } else { + insertCompiledTheme(project, host, workspace); + } + return host; + }; +} + +/** 将预设样式写入 theme.less,并添加到 angular.json */ +function insertCustomTheme(project: Project, host: Tree, workspace: Workspace): void { + const themePath = 'src/theme.less'; + host.create(themePath, createCustomTheme()); + if (project.architect) { + addStyleToTarget(project.architect.build, host, themePath, workspace); + addStyleToTarget(project.architect.test, host, themePath, workspace); + } else { + throw new SchematicsException(`${project.name} does not have an architect configuration`); + } +} + +/** 设置引导页面到 app.component.ts */ +function setBootstrapPage(): (host: Tree) => Tree { + return (host: Tree) => { + host.overwrite('src/app/app.component.html', ` + + +`); + return host; + }; + +} + +/** 安装依赖 */ +function installNodeDeps(): (host: Tree, context: SchematicContext) => void { + return (host: Tree, context: SchematicContext) => { + context.addTask(new NodePackageInstallTask()); + }; +} + +/** 将编译的 css 添加到 angular.json */ +function insertCompiledTheme(project: Project, host: Tree, workspace: Workspace): void { + const themePath = `node_modules/ng-zorro-antd/src/ng-zorro-antd.min.css`; + + if (project.architect) { + addStyleToTarget(project.architect.build, host, themePath, workspace); + addStyleToTarget(project.architect.test, host, themePath, workspace); + } else { + throw new SchematicsException(`${project.name} does not have an architect configuration`); + } +} + +/** Adds a style entry to the given target. */ +function addStyleToTarget(target: any, host: Tree, asset: string, workspace: Workspace): void { + const styleEntry = asset; + + // We can't assume that any of these properties are defined, so safely add them as we go + // if necessary. + if (!target.options) { + target.options = { styles: [ styleEntry ] }; + } else if (!target.options.styles) { + target.options.styles = [ styleEntry ]; + } else { + const existingStyles = target.options.styles.map(s => typeof s === 'string' ? s : s.input); + const hasGivenTheme = existingStyles.find(s => s.includes(asset)); + + if (!hasGivenTheme) { + target.options.styles.splice(0, 0, styleEntry); + } + } + + host.overwrite('angular.json', JSON.stringify(workspace, null, 2)); +} diff --git a/schematics/ng-add/schema.json b/schematics/ng-add/schema.json new file mode 100644 index 0000000000..41747d523a --- /dev/null +++ b/schematics/ng-add/schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "ngAdd", + "title": "add NG-ZORRO", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add ng-zorro dependencies to package.json (e.g., --skipPackageJson)" + }, + "locale": { + "type": "string", + "default": "zh_CN", + "enum": ["ar_EG","bg_BG","ca_ES","cs_CZ","de_DE","el_GR","en_GB","en_US","es_ES","et_EE","fa_IR","fi_FI","fr_BE","fr_FR","is_IS","it_IT","ja_JP","ko_KR","nb_NO","nl_BE","nl_NL","pl_PL","pt_BR","pt_PT","sk_SK","sr_RS","sv_SE","th_TH","tr_TR","ru_RU","uk_UA","vi_VN","zh_CN","zh_TW"], + "description": "add locale code to module (e.g., --locale=en_US)" + }, + "theme": { + "type": "boolean", + "default": false, + "description": "add theme.less" + } + }, + "required": [] +} \ No newline at end of file diff --git a/schematics/ng-add/schema.ts b/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..ee150769bf --- /dev/null +++ b/schematics/ng-add/schema.ts @@ -0,0 +1,12 @@ + +export interface Schema { + /** Whether to skip package.json install. */ + skipPackageJson: boolean; + + theme: boolean; + + /** Name of the project to target. */ + project?: string; + + locale: 'ar_EG' | 'bg_BG' | 'ca_ES' | 'cs_CZ' | 'de_DE' | 'el_GR' | 'en_GB' | 'en_US' | 'es_ES' | 'et_EE' | 'fa_IR' | 'fi_FI' | 'fr_BE' | 'fr_FR' | 'is_IS' | 'it_IT' | 'ja_JP' | 'ko_KR' | 'nb_NO' | 'nl_BE' | 'nl_NL' | 'pl_PL' | 'pt_BR' | 'pt_PT' | 'sk_SK' | 'sr_RS' | 'sv_SE' | 'th_TH' | 'tr_TR' | 'ru_RU' | 'uk_UA' | 'vi_VN' | 'zh_CN' | 'zh_TW'; +} diff --git a/schematics/tsconfig.json b/schematics/tsconfig.json new file mode 100644 index 0000000000..9c0ac514c7 --- /dev/null +++ b/schematics/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["es2017", "dom"], + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../publish/schematics", + "noEmitOnError": false, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "target": "es6", + "types": [ + "jasmine", + "node" + ] + }, + "include": [ + "**/*" + ], + "exclude": [ + "**/*.component.ts", + "**/*spec*", + "template/**/*" + ] +} \ No newline at end of file diff --git a/schematics/utils/ast.ts b/schematics/utils/ast.ts new file mode 100755 index 0000000000..3eb9e8cf1c --- /dev/null +++ b/schematics/utils/ast.ts @@ -0,0 +1,103 @@ +import {normalize} from '@angular-devkit/core'; +import {SchematicsException, Tree} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import {addImportToModule} from './devkit-utils/ast-utils'; +import {InsertChange} from './devkit-utils/change'; +import {Project, getWorkspace} from './devkit-utils/config'; +import {findBootstrapModulePath, getAppModulePath} from './devkit-utils/ng-ast-utils'; +import {ModuleOptions, findModuleFromOptions as internalFindModule} from './devkit-utils/find-module'; + + +/** Reads file given path and returns TypeScript source file. */ +export function getSourceFile(host: Tree, path: string): ts.SourceFile { + const buffer = host.read(path); + if (!buffer) { + throw new SchematicsException(`Could not find file for path: ${path}`); + } + const content = buffer.toString(); + return ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); +} + +/** Import and add module to root app module. */ +export function addModuleImportToRootModule(host: Tree, moduleName: string, src: string, project: Project) { + const modulePath = getAppModulePath(host, project.architect.build.options.main); + addModuleImportToModule(host, modulePath, moduleName, src); +} + +/** + * Import and add module to specific module path. + * @param host the tree we are updating + * @param modulePath src location of the module to import + * @param moduleName name of module to import + * @param src src location to import + */ +export function addModuleImportToModule( + host: Tree, modulePath: string, moduleName: string, src: string) { + const moduleSource = getSourceFile(host, modulePath); + + if (!moduleSource) { + throw new SchematicsException(`Module not found: ${modulePath}`); + } + + const changes = addImportToModule(moduleSource, modulePath, moduleName, src); + const recorder = host.beginUpdate(modulePath); + + changes.forEach((change) => { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + }); + + host.commitUpdate(recorder); +} + +/** Gets the app index.html file */ +export function getIndexHtmlPath(host: Tree, project: Project): string { + const buildTarget = project.architect.build.options; + + if (buildTarget.index && buildTarget.index.endsWith('index.html')) { + return buildTarget.index; + } + + throw new SchematicsException('No index.html file was found.'); +} + +/** Get the root stylesheet file. */ +export function getStylesPath(host: Tree, project: Project): string { + const buildTarget = project.architect['build']; + + if (buildTarget.options && buildTarget.options.styles && buildTarget.options.styles.length) { + const styles = buildTarget.options.styles.map(s => typeof s === 'string' ? s : s.input); + + // First, see if any of the assets is called "styles.(le|sc|c)ss", which is the default + // "main" style sheet. + const defaultMainStylePath = styles.find(a => /styles\.(c|le|sc)ss/.test(a)); + if (defaultMainStylePath) { + return normalize(defaultMainStylePath); + } + + // If there was no obvious default file, use the first style asset. + const fallbackStylePath = styles.find(a => /\.(c|le|sc)ss/.test(a)); + if (fallbackStylePath) { + return normalize(fallbackStylePath); + } + } + + throw new SchematicsException('No style files could be found into which a theme could be added'); +} + +/** Wraps the internal find module from options with undefined path handling */ +export function findModuleFromOptions(host: Tree, options: any) { + const workspace = getWorkspace(host); + if (!options.project) { + options.project = Object.keys(workspace.projects)[0]; + } + + const project = workspace.projects[options.project]; + + if (options.path === undefined) { + options.path = `/${project.root}/src/app`; + } + + return internalFindModule(host, options); +} diff --git a/schematics/utils/custom-theme.ts b/schematics/utils/custom-theme.ts new file mode 100644 index 0000000000..b36a256ef5 --- /dev/null +++ b/schematics/utils/custom-theme.ts @@ -0,0 +1,4 @@ +export function createCustomTheme() { + return `@import "~ng-zorro-antd/src/ng-zorro-antd.less"; +{{content}}`; +} diff --git a/schematics/utils/devkit-utils/README.md b/schematics/utils/devkit-utils/README.md new file mode 100755 index 0000000000..7c6ca2b68b --- /dev/null +++ b/schematics/utils/devkit-utils/README.md @@ -0,0 +1,2 @@ +# NOTE +This code is directly taken from [angular schematics package](https://github.com/angular/devkit/tree/master/packages/schematics/angular/utility). \ No newline at end of file diff --git a/schematics/utils/devkit-utils/ast-utils.ts b/schematics/utils/devkit-utils/ast-utils.ts new file mode 100755 index 0000000000..253c9692dc --- /dev/null +++ b/schematics/utils/devkit-utils/ast-utils.ts @@ -0,0 +1,480 @@ +/** + * @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 * as ts from 'typescript'; +import { Change, InsertChange } from './change'; +import { insertImport } from './route-utils'; + + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max = Infinity): ts.Node[] { + if (!node || max == 0) { + return []; + } + + const arr: ts.Node[] = []; + if (node.kind === kind) { + arr.push(node); + max--; + } + if (max > 0) { + for (const child of node.getChildren()) { + findNodes(child, kind, max).forEach(node => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + + return arr; +} + + +/** + * Get all the nodes from a source. + * @param sourceFile The source file object. + * @returns {Observable} An observable of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { + const nodes: ts.Node[] = [sourceFile]; + const result = []; + + while (nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + result.push(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + return result; +} + +export function findNode(node: ts.Node, kind: ts.SyntaxKind, text: string): ts.Node | null { + if (node.kind === kind && node.getText() === text) { + // throw new Error(node.getText()); + return node; + } + + let foundNode: ts.Node | null = null; + ts.forEachChild(node, childNode => { + foundNode = foundNode || findNode(childNode, kind, text); + }); + + return foundNode; +} + + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.getStart() - second.getStart(); +} + + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence(nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind): Change { + let lastItem = nodes.sort(nodesByPosition).pop(); + if (!lastItem) { + throw new Error(); + } + if (syntaxKind) { + lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); + } + const lastItemPosition: number = lastItem ? lastItem.getEnd() : fallbackPos; + + return new InsertChange(file, lastItemPosition, toInsert); +} + + +export function getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null { + if (node.kind == ts.SyntaxKind.Identifier) { + return (node as ts.Identifier).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + return (node as ts.StringLiteral).text; + } else { + return null; + } +} + + +function _angularImportsFromNode(node: ts.ImportDeclaration, + _sourceFile: ts.SourceFile): {[name: string]: string} { + const ms = node.moduleSpecifier; + let modulePath: string; + switch (ms.kind) { + case ts.SyntaxKind.StringLiteral: + modulePath = (ms as ts.StringLiteral).text; + break; + default: + return {}; + } + + if (!modulePath.startsWith('@angular/')) { + return {}; + } + + if (node.importClause) { + if (node.importClause.name) { + // This is of the form `import Name from 'path'`. Ignore. + return {}; + } else if (node.importClause.namedBindings) { + const nb = node.importClause.namedBindings; + if (nb.kind == ts.SyntaxKind.NamespaceImport) { + // This is of the form `import * as name from 'path'`. Return `name.`. + return { + [(nb as ts.NamespaceImport).name.text + '.']: modulePath, + }; + } else { + // This is of the form `import {a,b,c} from 'path'` + const namedImports = nb as ts.NamedImports; + + return namedImports.elements + .map((is: ts.ImportSpecifier) => is.propertyName ? is.propertyName.text : is.name.text) + .reduce((acc: {[name: string]: string}, curr: string) => { + acc[curr] = modulePath; + + return acc; + }, {}); + } + } + + return {}; + } else { + // This is of the form `import 'path';`. Nothing to do. + return {}; + } +} + + +export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, + module: string): ts.Node[] { + const angularImports: {[name: string]: string} + = findNodes(source, ts.SyntaxKind.ImportDeclaration) + .map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source)) + .reduce((acc: {[name: string]: string}, current: {[name: string]: string}) => { + for (const key of Object.keys(current)) { + acc[key] = current[key]; + } + + return acc; + }, {}); + + return getSourceNodes(source) + .filter(node => { + return node.kind == ts.SyntaxKind.Decorator + && (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression; + }) + .map(node => (node as ts.Decorator).expression as ts.CallExpression) + .filter(expr => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression as ts.Identifier; + + return id.getFullText(source) == identifier + && angularImports[id.getFullText(source)] === module; + } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression as ts.PropertyAccessExpression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name.text; + const moduleId = (paExpr.expression as ts.Identifier).getText(source); + + return id === identifier && (angularImports[moduleId + '.'] === module); + } + + return false; + }) + .filter(expr => expr.arguments[0] + && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) + .map(expr => expr.arguments[0] as ts.ObjectLiteralExpression); +} + +function findClassDeclarationParent(node: ts.Node): ts.ClassDeclaration|undefined { + if (ts.isClassDeclaration(node)) { + return node; + } + + return node.parent && findClassDeclarationParent(node.parent); +} + +/** + * Given a source file with @NgModule class(es), find the name of the first @NgModule class. + * + * @param source source file containing one or more @NgModule + * @returns the name of the first @NgModule, or `undefined` if none is found + */ +export function getFirstNgModuleName(source: ts.SourceFile): string|undefined { + // First, find the @NgModule decorators. + const ngModulesMetadata = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + if (ngModulesMetadata.length === 0) { + return undefined; + } + + // Then walk parent pointers up the AST, looking for the ClassDeclaration parent of the NgModule + // metadata. + const moduleClass = findClassDeclarationParent(ngModulesMetadata[0]); + if (!moduleClass || !moduleClass.name) { + return undefined; + } + + // Get the class name of the module ClassDeclaration. + return moduleClass.name.text; +} + +export function addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string | null = null, +): Change[] { + const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + let node: any = nodes[0]; // tslint:disable-line:no-any + + // Find the decorator declaration. + if (!node) { + return []; + } + + // Get all the children property assignment of object literals. + const matchingProperties: ts.ObjectLiteralElement[] = + (node as ts.ObjectLiteralExpression).properties + .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter((prop: ts.PropertyAssignment) => { + const name = prop.name; + switch (name.kind) { + case ts.SyntaxKind.Identifier: + return (name as ts.Identifier).getText(source) == metadataField; + case ts.SyntaxKind.StringLiteral: + return (name as ts.StringLiteral).text == metadataField; + } + + return false; + }); + + // Get the last node of the array literal. + if (!matchingProperties) { + return []; + } + if (matchingProperties.length == 0) { + // We haven't found the field in the metadata declaration. Insert a new field. + const expr = node as ts.ObjectLiteralExpression; + let position: number; + let toInsert: string; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n\s*/); + if (matches.length > 0) { + toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath), + ]; + } else { + return [new InsertChange(ngModulePath, position, toInsert)]; + } + } + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return []; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + if (arrLiteral.elements.length == 0) { + // Forward the property. + node = arrLiteral; + } else { + node = arrLiteral.elements; + } + + if (!node) { + console.log('No app module found. Please add your new class to your component.'); + + return []; + } + + if (Array.isArray(node)) { + const nodeArray = node as {} as Array; + const symbolsArray = nodeArray.map(node => node.getText()); + if (symbolsArray.includes(symbolName)) { + return []; + } + + node = node[node.length - 1]; + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + // We haven't found the field in the metadata declaration. Insert a new + // field. + const expr = node as ts.ObjectLiteralExpression; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match('^\r?\r?\n')) { + toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `${symbolName}`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match(/^\r?\n/)) { + toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath), + ]; + } + + return [new InsertChange(ngModulePath, position, toInsert)]; +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addDeclarationToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata( + source, modulePath, 'declarations', classifiedName, importPath); +} + +/** + * Custom function to insert an NgModule into NgModule imports. It also imports the module. + */ +export function addImportToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + + return addSymbolToNgModuleMetadata(source, modulePath, 'imports', classifiedName, importPath); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'providers', classifiedName, importPath); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addExportToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'exports', classifiedName, importPath); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addBootstrapToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'bootstrap', classifiedName, importPath); +} + +/** + * Determine if an import already exists. + */ +export function isImported(source: ts.SourceFile, + classifiedName: string, + importPath: string): boolean { + const allNodes = getSourceNodes(source); + const matchingNodes = allNodes + .filter(node => node.kind === ts.SyntaxKind.ImportDeclaration) + .filter((imp: ts.ImportDeclaration) => imp.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral) + .filter((imp: ts.ImportDeclaration) => { + return ( imp.moduleSpecifier).text === importPath; + }) + .filter((imp: ts.ImportDeclaration) => { + if (!imp.importClause) { + return false; + } + const nodes = findNodes(imp.importClause, ts.SyntaxKind.ImportSpecifier) + .filter(n => n.getText() === classifiedName); + + return nodes.length > 0; + }); + + return matchingNodes.length > 0; +} diff --git a/schematics/utils/devkit-utils/change.ts b/schematics/utils/devkit-utils/change.ts new file mode 100755 index 0000000000..12556352ab --- /dev/null +++ b/schematics/utils/devkit-utils/change.ts @@ -0,0 +1,127 @@ +/** + * @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 interface Host { + write(path: string, content: string): Promise; + read(path: string): Promise; +} + + +export interface Change { + apply(host: Host): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.'; + order = Infinity; + path = null; + apply() { return Promise.resolve(); } +} + + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + + order: number; + description: string; + + constructor(public path: string, public pos: number, public toAdd: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(host: Host) { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos); + + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + + order: number; + description: string; + + constructor(public path: string, private pos: number, private toRemove: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.toRemove.length); + + // TODO: throw error if toRemove doesn't match removed string. + return host.write(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor(public path: string, private pos: number, private oldText: string, + private newText: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`)); + } + + // TODO: throw error if oldText doesn't match removed string. + return host.write(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} diff --git a/schematics/utils/devkit-utils/component.ts b/schematics/utils/devkit-utils/component.ts new file mode 100755 index 0000000000..b50fa17863 --- /dev/null +++ b/schematics/utils/devkit-utils/component.ts @@ -0,0 +1,136 @@ +import {normalize, strings} from '@angular-devkit/core'; +import { + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + Rule, + SchematicContext, + SchematicsException, + template, + Tree, + url, +} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import {addDeclarationToModule, addExportToModule} from './ast-utils'; +import {InsertChange} from './change'; +import {buildRelativePath, findModuleFromOptions} from './find-module'; +import {getWorkspace} from './config'; +import {parseName} from './parse-name'; +import {validateName} from './validation'; + +function addDeclarationToNgModule(options: any): Rule { + return (host: Tree) => { + if (options.skipImport || !options.module) { + return host; + } + + const modulePath = options.module; + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true); + + const componentPath = `/${options.path}/` + + (options.flat ? '' : strings.dasherize(options.name) + '/') + + strings.dasherize(options.name) + + '.component'; + const relativePath = buildRelativePath(modulePath, componentPath); + const classifiedName = strings.classify(`${options.name}Component`); + const declarationChanges = addDeclarationToModule(source, + modulePath, + classifiedName, + relativePath); + + const declarationRecorder = host.beginUpdate(modulePath); + for (const change of declarationChanges) { + if (change instanceof InsertChange) { + declarationRecorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(declarationRecorder); + + if (options.export) { + // Need to refresh the AST because we overwrote the file in the host. + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true); + + const exportRecorder = host.beginUpdate(modulePath); + const exportChanges = addExportToModule(source, modulePath, + strings.classify(`${options.name}Component`), + relativePath); + + for (const change of exportChanges) { + if (change instanceof InsertChange) { + exportRecorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(exportRecorder); + } + + + return host; + }; +} + + +function buildSelector(options: any) { + let selector = strings.dasherize(options.name); + if (options.prefix) { + selector = `${options.prefix}-${selector}`; + } + + return selector; +} + + +export function buildComponent(options: any): Rule { + return (host: Tree, context: SchematicContext) => { + const workspace = getWorkspace(host); + if (!options.project) { + options.project = Object.keys(workspace.projects)[0]; + } + const project = workspace.projects[options.project]; + + if (options.path === undefined) { + options.path = `/${project.root}/src/app`; + } + + options.selector = options.selector || buildSelector(options); + options.module = findModuleFromOptions(host, options); + + const parsedPath = parseName(options.path, options.name); + options.name = parsedPath.name; + options.path = parsedPath.path; + + validateName(options.name); + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('.spec.ts')), + options.inlineStyle ? filter(path => !path.endsWith('.__styleext__')) : noop(), + options.inlineTemplate ? filter(path => !path.endsWith('.html')) : noop(), + template({ + ...strings, + 'if-flat': (s: string) => options.flat ? '' : s, + ...options, + }), + move(null, parsedPath.path), + ]); + + return chain([ + branchAndMerge(chain([ + addDeclarationToNgModule(options), + mergeWith(templateSource), + ])), + ])(host, context); + }; +} diff --git a/schematics/utils/devkit-utils/config.ts b/schematics/utils/devkit-utils/config.ts new file mode 100755 index 0000000000..838e44a3dc --- /dev/null +++ b/schematics/utils/devkit-utils/config.ts @@ -0,0 +1,120 @@ +/** + * @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 {SchematicsException, Tree} from '@angular-devkit/schematics'; + +export const ANGULAR_CLI_WORKSPACE_PATH = '/angular.json'; + + +/** An Angular CLI Workspacer config (angular.json) */ +export interface Workspace { + /** Link to schema. */ + $schema?: string; + /** Workspace Schema version. */ + version: number; + /** New project root. */ + newProjectRoot?: string; + /** Tool options. */ + cli?: { + /** Link to schema. */ + $schema?: string; + [k: string]: any; + }; + /** Tool options. */ + schematics?: { + /** Link to schema. */ + $schema?: string; + [k: string]: any; + }; + /** Tool options. */ + architect?: { + /** Link to schema. */ + $schema?: string; + [k: string]: any; + }; + /** A map of project names to project options. */ + projects: { + [k: string]: Project; + }; +} + +/** + * A project in an Angular CLI workspace (e.g. an app or a library). A single workspace + * can house multiple projects. + */ +export interface Project { + name: string; + + /** Project type. */ + projectType: 'application' | 'library'; + /** Root of the project sourcefiles. */ + root: string; + /** Tool options. */ + cli?: { + /** Link to schema. */ + $schema?: string; + [k: string]: any; + }; + /** Tool options. */ + schematics?: { + /** Link to schema. */ + $schema?: string; + [k: string]: any; + }; + /** Tool options. */ + architect?: ProjectBuildOptions; +} + +/** Architect options for an Angular CLI workspace. */ +export interface ProjectBuildOptions { + /** Link to schema. */ + $schema?: string; + [k: string]: any; +} + +/** Gets the Angular CLI workspace config (angular.json) */ +export function getWorkspace(host: Tree): Workspace { + const configBuffer = host.read(ANGULAR_CLI_WORKSPACE_PATH); + if (configBuffer === null) { + throw new SchematicsException('Could not find angular.json'); + } + + return JSON.parse(configBuffer.toString()); +} + +/** + * Gets a project from the Angular CLI workspace. If no project name is given, the first project + * will be retrieved. + */ +export function getProjectFromWorkspace(config: Workspace, projectName?: string): Project { + if (config.projects) { + if (projectName) { + const project = config.projects[projectName]; + if (!project) { + throw new SchematicsException(`No project named "${projectName}" exists.`); + } + + Object.defineProperty(project, 'name', {enumerable: false, value: projectName}); + return project; + } + + // If there is exactly one non-e2e project, use that. Otherwise, require that a specific + // project be specified. + const allProjectNames = Object.keys(config.projects).filter(p => !p.includes('e2e')); + if (allProjectNames.length === 1) { + const project = config.projects[allProjectNames[0]]; + // Set a non-enumerable project name to the project. We need the name for schematics + // later on, but don't want to write it back out to the config file. + Object.defineProperty(project, 'name', {enumerable: false, value: projectName}); + return project; + } else { + throw new SchematicsException('Multiple projects are defined; please specify a project name'); + } + } + + throw new SchematicsException('No projects are defined'); +} diff --git a/schematics/utils/devkit-utils/find-module.ts b/schematics/utils/devkit-utils/find-module.ts new file mode 100755 index 0000000000..cf55bbd2a8 --- /dev/null +++ b/schematics/utils/devkit-utils/find-module.ts @@ -0,0 +1,108 @@ +/** + * @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 { Path, join, normalize, relative, strings } from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; + + +export interface ModuleOptions { + module?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; +} + + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions(host: Tree, options: ModuleOptions): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + if (!options.module) { + const pathToCheck = (options.path || '') + + (options.flat ? '' : '/' + strings.dasherize(options.name)); + + return normalize(findModule(host, pathToCheck)); + } else { + const modulePath = normalize( + '/' + (options.path) + '/' + options.module); + const moduleBaseName = normalize(modulePath).split('/').pop(); + + if (host.exists(modulePath)) { + return normalize(modulePath); + } else if (host.exists(modulePath + '.ts')) { + return normalize(modulePath + '.ts'); + } else if (host.exists(modulePath + '.module.ts')) { + return normalize(modulePath + '.module.ts'); + } else if (host.exists(modulePath + '/' + moduleBaseName + '.module.ts')) { + return normalize(modulePath + '/' + moduleBaseName + '.module.ts'); + } else { + throw new Error('Specified module does not exist'); + } + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const moduleRe = /\.module\.ts$/; + const routingModuleRe = /-routing\.module\.ts/; + + while (dir) { + const matches = dir.subfiles.filter(p => moduleRe.test(p) && !routingModuleRe.test(p)); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error('More than one module matches. Use skip-import option to skip importing ' + + 'the component into the closest module.'); + } + + dir = dir.parent; + } + + throw new Error('Could not find an NgModule. Use the skip-import ' + + 'option to skip importing in NgModule.'); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + from = normalize(from); + to = normalize(to); + + // Convert to arrays. + const fromParts = from.split('/'); + const toParts = to.split('/'); + + // Remove file names (preserving destination) + fromParts.pop(); + const toFileName = toParts.pop(); + + const relativePath = relative(normalize(fromParts.join('/')), normalize(toParts.join('/'))); + let pathPrefix = ''; + + // Set the path prefix for same dir or child dir, parent dir starts with `..` + if (!relativePath) { + pathPrefix = '.'; + } else if (!relativePath.startsWith('.')) { + pathPrefix = `./`; + } + if (pathPrefix && !pathPrefix.endsWith('/')) { + pathPrefix += '/'; + } + + return pathPrefix + (relativePath ? relativePath + '/' : '') + toFileName; +} diff --git a/schematics/utils/devkit-utils/ng-ast-utils.ts b/schematics/utils/devkit-utils/ng-ast-utils.ts new file mode 100755 index 0000000000..74fd4c669f --- /dev/null +++ b/schematics/utils/devkit-utils/ng-ast-utils.ts @@ -0,0 +1,84 @@ +/** + * @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 { SchematicsException, Tree } from '@angular-devkit/schematics'; +import { dirname } from 'path'; +import * as ts from 'typescript'; +import { findNode, getSourceNodes } from './ast-utils'; + +export function findBootstrapModuleCall(host: Tree, mainPath: string): ts.CallExpression | null { + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException(`Main file (${mainPath}) not found`); + } + const mainText = mainBuffer.toString('utf-8'); + const source = ts.createSourceFile(mainPath, mainText, ts.ScriptTarget.Latest, true); + + const allNodes = getSourceNodes(source); + + let bootstrapCall: ts.CallExpression | null = null; + + for (const node of allNodes) { + + let bootstrapCallNode: ts.Node | null = null; + bootstrapCallNode = findNode(node, ts.SyntaxKind.Identifier, 'bootstrapModule'); + + // Walk up the parent until CallExpression is found. + while (bootstrapCallNode && bootstrapCallNode.parent + && bootstrapCallNode.parent.kind !== ts.SyntaxKind.CallExpression) { + + bootstrapCallNode = bootstrapCallNode.parent; + } + + if (bootstrapCallNode !== null && + bootstrapCallNode.parent !== undefined && + bootstrapCallNode.parent.kind === ts.SyntaxKind.CallExpression) { + bootstrapCall = bootstrapCallNode.parent as ts.CallExpression; + break; + } + } + + return bootstrapCall; +} + +export function findBootstrapModulePath(host: Tree, mainPath: string): string { + const bootstrapCall = findBootstrapModuleCall(host, mainPath); + if (!bootstrapCall) { + throw new SchematicsException('Bootstrap call not found'); + } + + const bootstrapModule = bootstrapCall.arguments[0]; + + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException(`Client app main file (${mainPath}) not found`); + } + const mainText = mainBuffer.toString('utf-8'); + const source = ts.createSourceFile(mainPath, mainText, ts.ScriptTarget.Latest, true); + const allNodes = getSourceNodes(source); + const bootstrapModuleRelativePath = allNodes + .filter(node => node.kind === ts.SyntaxKind.ImportDeclaration) + .filter(imp => { + return findNode(imp, ts.SyntaxKind.Identifier, bootstrapModule.getText()); + }) + .map((imp: ts.ImportDeclaration) => { + const modulePathStringLiteral = imp.moduleSpecifier; + + return modulePathStringLiteral.text; + })[0]; + + return bootstrapModuleRelativePath; +} + +export function getAppModulePath(host: Tree, mainPath: string): string { + const moduleRelativePath = findBootstrapModulePath(host, mainPath); + const mainDir = dirname(mainPath); + const modulePath = normalize(`/${mainDir}/${moduleRelativePath}.ts`); + + return modulePath; +} diff --git a/schematics/utils/devkit-utils/parse-name.ts b/schematics/utils/devkit-utils/parse-name.ts new file mode 100755 index 0000000000..12d2e89613 --- /dev/null +++ b/schematics/utils/devkit-utils/parse-name.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 { Path, basename, dirname, normalize } from '@angular-devkit/core'; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(name as Path); + const namePath = dirname((path + '/' + name) as Path); + + return { + name: nameWithoutPath, + path: normalize('/' + namePath), + }; +} diff --git a/schematics/utils/devkit-utils/route-utils.ts b/schematics/utils/devkit-utils/route-utils.ts new file mode 100755 index 0000000000..67e7e4974d --- /dev/null +++ b/schematics/utils/devkit-utils/route-utils.ts @@ -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 + */ +import * as ts from 'typescript'; +import { findNodes, insertAfterLastOccurrence } from './ast-utils'; +import { Change, NoopChange } from './change'; + + +/** +* Add Import `import { symbolName } from fileName` if the import doesn't exit +* already. Assumes fileToEdit can be resolved and accessed. +* @param fileToEdit (file we want to add import to) +* @param symbolName (item to import) +* @param fileName (path to the file) +* @param isDefault (if true, import follows style for importing default exports) +* @return Change +*/ + +export function insertImport(source: ts.SourceFile, fileToEdit: string, symbolName: string, + fileName: string, isDefault = false): Change { + const rootNode = source; + const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + const relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + const importFiles = node.getChildren() + .filter(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n as ts.StringLiteral).text); + + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + let importsAsterisk = false; + // imports from import file + const imports: ts.Node[] = []; + relevantImports.forEach(n => { + Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier)); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return new NoopChange(); + } + + const importTextNodes = imports.filter(n => (n as ts.Identifier).text === symbolName); + + // insert import if it's not there + if (importTextNodes.length === 0) { + const fallbackPos = + findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].getStart() || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); + + return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) + .filter((n: ts.StringLiteral) => n.text === 'use strict'); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? '' : '{ '; + const close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + const separator = insertAtBeginning ? '' : ';\n'; + const toInsert = `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; + + return insertAfterLastOccurrence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + ts.SyntaxKind.StringLiteral, + ); +} diff --git a/schematics/utils/devkit-utils/validation.ts b/schematics/utils/devkit-utils/validation.ts new file mode 100755 index 0000000000..dc80585efa --- /dev/null +++ b/schematics/utils/devkit-utils/validation.ts @@ -0,0 +1,16 @@ +/** + * @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 { tags } from '@angular-devkit/core'; +import { SchematicsException } from '@angular-devkit/schematics'; + +export function validateName(name: string): void { + if (name && /^\d/.test(name)) { + throw new SchematicsException(tags.oneLine`name (${name}) + can not start with a digit.`); + } +} diff --git a/schematics/utils/html.ts b/schematics/utils/html.ts new file mode 100755 index 0000000000..0ae50b07ca --- /dev/null +++ b/schematics/utils/html.ts @@ -0,0 +1,63 @@ +import {Tree, SchematicsException} from '@angular-devkit/schematics'; +import * as parse5 from 'parse5'; +import {getIndexHtmlPath} from './ast'; +import {InsertChange} from './devkit-utils/change'; +import {Project} from './devkit-utils/config'; + +/** + * Parses the index.html file to get the HEAD tag position. + * @param host the tree we are traversing + * @param src the src path of the html file to parse + */ +export function getHeadTag(host: Tree, src: string) { + const document = parse5.parse(src, + {locationInfo: true}) as parse5.AST.Default.Document; + + let head; + const visit = (nodes: parse5.AST.Default.Node[]) => { + nodes.forEach(node => { + const element = node; + if (element.tagName === 'head') { + head = element; + } else { + if (element.childNodes) { + visit(element.childNodes); + } + } + }); + }; + + visit(document.childNodes); + + if (!head) { + throw new SchematicsException('Head element not found!'); + } + + return { + position: head.__location.startTag.endOffset + }; +} + +/** + * Adds a link to the index.html head tag Example: + * `` + * @param host The tree we are updating + * @param project The project we're targeting. + * @param link html element string we are inserting. + */ +export function addHeadLink(host: Tree, project: Project, link: string) { + const indexPath = getIndexHtmlPath(host, project); + const buffer = host.read(indexPath); + if (!buffer) { + throw new SchematicsException(`Could not find file for path: ${indexPath}`); + } + + const src = buffer.toString(); + if (src.indexOf(link) === -1) { + const node = getHeadTag(host, src); + const insertion = new InsertChange(indexPath, node.position, link); + const recorder = host.beginUpdate(indexPath); + recorder.insertLeft(insertion.pos, insertion.toAdd); + host.commitUpdate(recorder); + } +} diff --git a/schematics/utils/lib-versions.ts b/schematics/utils/lib-versions.ts new file mode 100755 index 0000000000..4b2f92b892 --- /dev/null +++ b/schematics/utils/lib-versions.ts @@ -0,0 +1 @@ +export const zorroVersion = 'ZORRO_VERSION'; diff --git a/schematics/utils/package.ts b/schematics/utils/package.ts new file mode 100755 index 0000000000..41e53e009d --- /dev/null +++ b/schematics/utils/package.ts @@ -0,0 +1,23 @@ +import {Tree} from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, type: string, pkg: string, version: string): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/schematics/utils/testing.ts b/schematics/utils/testing.ts new file mode 100755 index 0000000000..3030365969 --- /dev/null +++ b/schematics/utils/testing.ts @@ -0,0 +1,25 @@ +import {join} from 'path'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; + +const collectionPath = join('./node_modules/@schematics/angular/collection.json'); + +/** + * Create a base app used for testing. + */ +export function createTestApp(): UnitTestTree { + const baseRunner = new SchematicTestRunner('schematics', collectionPath); + return baseRunner.runSchematic('application', { + directory: '', + name: 'app', + prefix: 'app', + sourceDir: 'src', + inlineStyle: false, + inlineTemplate: false, + viewEncapsulation: 'None', + version: '1.2.3', + routing: true, + style: 'scss', + skipTests: false, + minimal: false, + }); +} diff --git a/schematics_script/copy-resources.js b/schematics_script/copy-resources.js new file mode 100644 index 0000000000..505d222e86 --- /dev/null +++ b/schematics_script/copy-resources.js @@ -0,0 +1,22 @@ +const fs = require('fs-extra'); +const path = require('path'); + +const srcPath = path.resolve(__dirname, `../schematics`); +const targetPath = path.resolve(__dirname, `../publish/schematics`); +const copyFilter = (path) => (!/.+\.ts/.test(path)) || (/files\/__path__/.test(path)); + + +function mergeDemoCollection() { + const demoCollectionPath = path.resolve(targetPath, `demo/collection.json`); + const targetCollectionPath = path.resolve(targetPath, `collection.json`); + const demoCollectionJson = fs.readJsonSync(demoCollectionPath, { throws: false }) || {schematics: {}}; + const targetCollectionJson = fs.readJsonSync(targetCollectionPath, { throws: false }) || {schematics: {}}; + targetCollectionJson.schematics = Object.assign(targetCollectionJson.schematics, { + ...demoCollectionJson.schematics + }); + fs.outputJsonSync(targetCollectionPath, targetCollectionJson); + fs.removeSync(demoCollectionPath) +} + +fs.copySync(srcPath, targetPath, { filter: copyFilter }); +mergeDemoCollection(); \ No newline at end of file diff --git a/schematics_script/demo2schematics.js b/schematics_script/demo2schematics.js new file mode 100644 index 0000000000..536e105e21 --- /dev/null +++ b/schematics_script/demo2schematics.js @@ -0,0 +1,150 @@ +const fs = require('fs-extra'); +const path = require('path'); +const glob = require('glob').sync; + +const TEST_FILE_CONTENT = +`import { fakeAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component'; + +describe('<%= classify(name) %>Component', () => { + let component: <%= classify(name) %>Component; + let fixture: ComponentFixture<<%= classify(name) %>Component>; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + declarations: [ <%= classify(name) %>Component ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(<%= classify(name) %>Component); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); +`; + +/** + * @returns {string[]} + */ +function getComponentPaths() { + return glob(path.join(path.resolve(__dirname, '../components'), '**/demo/*.ts')) +} + +/** + * @param {string} filePath + * @returns {{fileContent: string, componentName: string, demoName: string, template: string, styles: string, selector: string, className: string}} + */ +function parse(filePath) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const pathSplitted = filePath.split('components/')[1].split('/'); + const componentName = pathSplitted[0] || ''; + const demoName = (pathSplitted[2] && pathSplitted[2].split('.')[0]) ? pathSplitted[2].split('.')[0] : ''; + const template = getTemplate(fileContent); + const styles = getStyles(fileContent); + const selector = getSelector(fileContent); + const className = getClassName(fileContent); + return { + fileContent, + componentName, + demoName, + template, + styles, + selector, + className + } +} + +/** + * @param {string} fileContent + * @returns {string} + */ +function getTemplate(fileContent) { + const match = fileContent.match(/template\s*:\s*`([\s\S]*?)`/); + return match ? match[1] || '' : ''; +} + +/** + * @param {string} fileContent + * @returns {string} + */ +function getStyles(fileContent) { + const match = fileContent.match(/styles\s*:\s*\[\s*`([\s\S]*?)`\s*\]/); + return match ? match[1] || '' : ''; +} + +/** + * @param {string} fileContent + * @returns {string} + */ +function getClassName(fileContent) { + const match = fileContent.match(/export\s*class\s*(.+?)\s.*/); + return match ? match[1] || '' : ''; +} + +/** + * @param {string} fileContent + * @returns {string} + */ +function getSelector(fileContent) { + const match = fileContent.match(/selector\s*:\s*'(.+?)'\s*/); + return match ? match[1] || '' : ''; +} + +function replaceTemplate(demoComponent) { + return demoComponent.fileContent + .replace(/selector\s*:\s*'(.+?)'\s*/, () => `selector: '<%= selector %>'`) + .replace(new RegExp(demoComponent.className), () => `<%= classify(name) %>Component`) + .replace(/styles\s*:\s*\[\s*`([\s\S]*?)`\s*\]/, () => `<% if(inlineStyle) { %>styles: [\`${demoComponent.styles}\`]<% } else { %>styleUrls: ['./<%= dasherize(name) %>.component.<%= styleext %>']<% } %>`) + .replace(/template\s*:\s*`([\s\S]*?)`/, () => `<% if(inlineTemplate) { %>template: \`${demoComponent.template}\`<% } else { %>templateUrl: './<%= dasherize(name) %>.component.html'<% } %>`) +} + +/** + * @param {{fileContent: string, componentName: string, demoName: string, template: string, styles: string, selector: string, className: string}} demoComponent + */ +function createSchematic(demoComponent) { + const demoPath = path.resolve(__dirname, `../schematics/demo/${demoComponent.componentName}-${demoComponent.demoName}`); + const filesPath = path.resolve(__dirname, `${demoPath}/files/__path__/__name@dasherize@if-flat__`); + const schemaPath = `${demoPath}/schema.json`; + const collectionPath = path.resolve(__dirname, `../schematics/demo/collection.json`); + fs.mkdirsSync(filesPath); + fs.copySync(path.resolve(__dirname, `./template`), demoPath); + + const schemaJson = fs.readJsonSync(schemaPath); + schemaJson.id = `${demoComponent.demoName}-${demoComponent.componentName}`; + schemaJson.title = `NG-ZORRO ${demoComponent.demoName} ${demoComponent.componentName}`; + fs.outputJsonSync(schemaPath, schemaJson); + + fs.outputFileSync(`${filesPath}/__name@dasherize__.component.__styleext__`, demoComponent.styles); + fs.outputFileSync(`${filesPath}/__name@dasherize__.component.html`, demoComponent.template); + fs.outputFileSync(`${filesPath}/__name@dasherize__.component.spec.ts`, TEST_FILE_CONTENT); + fs.outputFileSync(`${filesPath}/__name@dasherize__.component.ts`, replaceTemplate(demoComponent)); + + const collectionJson = fs.readJsonSync(collectionPath, { throws: false }) || {schematics: {}}; + collectionJson.schematics = Object.assign(collectionJson.schematics, { + [`${demoComponent.componentName}-${demoComponent.demoName}`]: { + description: schemaJson.title, + factory: `./demo/${demoComponent.componentName}-${demoComponent.demoName}`, + schema: `./demo/${demoComponent.componentName}-${demoComponent.demoName}/schema.json` + }, + }); + fs.outputJsonSync(collectionPath, collectionJson, {spaces: ' '}); +} + +function generate() { + const componentPath = getComponentPaths(); + componentPath.forEach(path => { + try { + createSchematic(parse(path)) + } catch (e) { + console.error(`error ${path}`); + console.error(e); + } + }); + console.log(`success(${componentPath.length})`) +} + +generate(); diff --git a/schematics_script/set-theme.js b/schematics_script/set-theme.js new file mode 100644 index 0000000000..353c909a36 --- /dev/null +++ b/schematics_script/set-theme.js @@ -0,0 +1,13 @@ +const fs = require('fs-extra'); +const path = require('path'); + +const theme = fs.readFileSync(path.resolve(__dirname, `../site_scripts/_site/src/theme.less`), 'utf8'); +fs.outputFileSync( + path.resolve(__dirname, `../schematics/utils/custom-theme.ts`), +`export function createCustomTheme() { + return \`@import "~ng-zorro-antd/src/ng-zorro-antd.less"; +${theme.replace(/`/g, '\\`')} +\`; +} +` +); diff --git a/schematics_script/set-version.js b/schematics_script/set-version.js new file mode 100644 index 0000000000..9bf0e34528 --- /dev/null +++ b/schematics_script/set-version.js @@ -0,0 +1,8 @@ +const fs = require('fs-extra'); +const path = require('path'); + +const packageJson = fs.readJsonSync(path.resolve(__dirname, `../package.json`)); +fs.outputFileSync( + path.resolve(__dirname, `../schematics/utils/lib-versions.ts`), + `export const zorroVersion = '^${packageJson.version}';\n` +); diff --git a/schematics_script/template/index.ts b/schematics_script/template/index.ts new file mode 100644 index 0000000000..4fabd42479 --- /dev/null +++ b/schematics_script/template/index.ts @@ -0,0 +1,9 @@ +import { chain, Rule } from '@angular-devkit/schematics'; +import { buildComponent } from '../../utils/devkit-utils/component'; +import { Schema } from './schema'; + +export default function (options: Schema): Rule { + return chain([ + buildComponent({ ...options }) + ]); +} diff --git a/schematics_script/template/schema.json b/schematics_script/template/schema.json new file mode 100644 index 0000000000..4d3241fd86 --- /dev/null +++ b/schematics_script/template/schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "AddNgZorroTable", + "title": "Add NG-ZORRO Table", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the component." + }, + "path": { + "type": "string", + "format": "path", + "description": "The path to create the component.", + "visible": false + }, + "project": { + "type": "string", + "description": "The name of the project.", + "visible": false + }, + "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" + }, + "prefix": { + "type": "string", + "format": "html-selector", + "description": "The prefix to apply to generated selectors.", + "alias": "p" + }, + "styleext": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css" + }, + "spec": { + "type": "boolean", + "description": "Specifies if a spec file is generated.", + "default": true + }, + "flat": { + "type": "boolean", + "description": "Flag to indicate if a dir is created.", + "default": false + }, + "skipImport": { + "type": "boolean", + "description": "Flag to skip the module import.", + "default": false + }, + "selector": { + "type": "string", + "format": "html-selector", + "description": "The selector to use for the component." + }, + "module": { + "type": "string", + "description": "Allows specification of the declaring module.", + "alias": "m" + }, + "export": { + "type": "boolean", + "default": false, + "description": "Specifies if declaring module exports the component." + } + }, + "required": [ + "name" + ] +} diff --git a/schematics_script/template/schema.ts b/schematics_script/template/schema.ts new file mode 100644 index 0000000000..49a799dd15 --- /dev/null +++ b/schematics_script/template/schema.ts @@ -0,0 +1,3 @@ +import {Schema as ComponentSchema} from '@schematics/angular/component/schema'; + +export interface Schema extends ComponentSchema {} diff --git a/site_scripts/_site/src/app/share/nz-codebox/nz-codebox.component.ts b/site_scripts/_site/src/app/share/nz-codebox/nz-codebox.component.ts index 042f2a8aeb..9b0603798c 100644 --- a/site_scripts/_site/src/app/share/nz-codebox/nz-codebox.component.ts +++ b/site_scripts/_site/src/app/share/nz-codebox/nz-codebox.component.ts @@ -43,6 +43,9 @@ import sdk from '@stackblitz/sdk'; + + + @@ -58,6 +61,7 @@ import sdk from '@stackblitz/sdk'; export class NzCodeBoxComponent implements OnInit { _code: string; _copied = false; + _commandCopied = false; showIframe: boolean; simulateIFrame: boolean; iframe: SafeUrl; @@ -70,6 +74,7 @@ export class NzCodeBoxComponent implements OnInit { @Input() nzRawCode = ''; @Input() nzComponentName = ''; @Input() nzSelector = ''; + @Input() nzGenerateCommand = ''; @Input() set nzIframeSource(value: string) { this.showIframe = (value != 'null') && environment.production; @@ -99,6 +104,15 @@ export class NzCodeBoxComponent implements OnInit { }); } + copyGenerateCommand(command) { + this.copy(command).then(() => { + this._commandCopied = true; + setTimeout(() => { + this._commandCopied = false; + }, 1000); + }); + } + copy(value: string): Promise { const promise = new Promise( diff --git a/site_scripts/template/code-box.template.html b/site_scripts/template/code-box.template.html index 1c565e3025..e1743293eb 100644 --- a/site_scripts/template/code-box.template.html +++ b/site_scripts/template/code-box.template.html @@ -1,4 +1,4 @@ - +
{{doc}} diff --git a/site_scripts/utils/generate-code-box.js b/site_scripts/utils/generate-code-box.js index 407a7b3abe..8b6f75bbde 100644 --- a/site_scripts/utils/generate-code-box.js +++ b/site_scripts/utils/generate-code-box.js @@ -29,5 +29,6 @@ module.exports = function generateCodeBox(component, key, title, doc, iframe) { } output = output.replace(/{{code}}/g, camelCase(key)); output = output.replace(/{{rawCode}}/g, `${camelCase(key)}Raw`); + output = output.replace(/{{nzGenerateCommand}}/g, `ng g ng-zorro-antd:${component}-${key} -p app --styleext='less' --name=`); return output; };