├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── README.md ├── docs ├── logo │ └── ng-samurai.png └── prerequisite │ ├── circular-dependencies.png │ ├── file-structure-invalid.png │ ├── file-structure-valid.png │ └── module-required.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── collection.json ├── library-split │ ├── index.spec.ts │ └── index.ts ├── rules │ ├── add-tsconfig-paths.rule.ts │ ├── update-import-paths.rule.ts │ └── update-public-api │ │ ├── update-public-api.rule.ts │ │ ├── update-subentry-public-api.rule.ts │ │ └── update-top-level-public-api.rule.ts ├── shared │ ├── log-helper.ts │ ├── path-helper.spec.ts │ └── path-helper.ts └── subentry │ ├── files │ └── __name@dasherize__ │ │ ├── index.ts │ │ ├── package.json │ │ └── public-api.ts │ ├── index.spec.ts │ ├── index.ts │ ├── schema.json │ └── schema.model.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Outputs 2 | src/**/*.js 3 | src/**/*.js.map 4 | src/**/*.d.ts 5 | 6 | # IDEs 7 | .idea/ 8 | jsconfig.json 9 | .vscode/ 10 | 11 | # Misc 12 | node_modules/ 13 | npm-debug.log* 14 | yarn-error.log* 15 | 16 | # Mac OSX Finder files. 17 | **/.DS_Store 18 | .DS_Store 19 | 20 | # Coverage 21 | coverage/ 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignores TypeScript files, but keeps definitions. 2 | !*.d.ts 3 | 4 | *.ts 5 | !**/files/**/*.{ts,json} 6 | 7 | .idea 8 | 9 | !package.json 10 | jest.config.js 11 | coverage/ 12 | tsconfig.json 13 | tslint.json 14 | .travis.yml 15 | .prettierrc 16 | .prettierignore 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/subentry/files/**/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: required 3 | language: node_js 4 | node_js: 5 | - '12' 6 | 7 | os: 8 | - linux 9 | 10 | jobs: 11 | include: 12 | - stage: install 13 | script: npm install 14 | skip_cleanup: true 15 | - stage: test 16 | script: npm run test 17 | skip_cleanup: true 18 | - stage: Build & publish 19 | script: 20 | - npm run build 21 | - npx semantic-release 22 | if: branch = master 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://raw.githubusercontent.com/kreuzerk/ng-samurai/master/docs/logo/ng-samurai.png) 2 | 3 | 4 | 5 | 6 | - [Overview](#overview) 7 | - [Installation](#installation) 8 | - [Getting started](#getting-started) 9 | - [Split](#split) 10 | - [Prerequisit for a successfull split](#prerequisit-for-a-successfull-split) 11 | - [Folder structure](#folder-structure) 12 | - [Circular dependencies](#circular-dependencies) 13 | - [Each file needs to belong to a module](#each-file-needs-to-belong-to-a-module) 14 | - [Generate subentry](#generate-subentry) 15 | - [Further resources](#further-resources) 16 | 17 | 18 | 19 | > Improve tree shaking of your Angular library - more details on this [blog post](https://medium.com/@kevinkreuzer/ng-samurai-schematics-to-improve-tree-shaking-of-angular-libraries-83656ca22d9e) 20 | 21 | # Overview 22 | 23 | Nowadays, thanks to the Angular CLI, libraries are easy to create. They are a great way to share code across multiple applications. 24 | Since they can be used in many places, performance is a critical aspect. A library that doesn’t perform can slow down multiple applications! 25 | 26 | > This [blogpost](https://medium.com/angular-in-depth/improve-spa-performance-by-splitting-your-angular-libraries-in-multiple-chunks-8c68103692d0) offers a detailed explenation how wrongly packaged libraries can increase the main bundle size and slow down applications initial load. 27 | 28 | Ng-packagr offers a great feature called subentries to improve tree shaking. There are a lot of things to be aware of 29 | when trying to convert your library to take advantage of subentries. 30 | 31 | Ng-samurai is an Angular schematic which automatically updates your library to take advantage of subentries and improve 32 | tree shaking. Furthermore, it helps you to quickly generate new subentries. 33 | 34 | # Installation 35 | 36 | ``` 37 | npm i -D ng-samurai 38 | ``` 39 | 40 | # Getting started 41 | 42 | Once ng-samurai is installed we have two different schematics commands available - one for spliting an existing library 43 | into multiple chunks (subentries) and another one for creating a new subentry. 44 | 45 | #Available schematics 46 | `ng-samurai` provides two schematics: `split-lib` and `generate-subentries` 47 | 48 | ## Split 49 | 50 | Spliting your libary automatically into multiple chunks our library project needs to fullfill a couple of cirterias: 51 | 52 | - Nesting of modules: Modules used by other modules can only be siblings and never children. There should always be one 53 | only one module per subentry. 54 | 55 | Go ahead and run the following command in the root of your project: 56 | 57 | ``` 58 | ng g ng-samurai:split-lib 59 | ``` 60 | 61 | This will do the following things: 62 | 63 | - Will convert each folder where it encounters a module to a subentry - it will add a (`index.ts`, `public-api.ts`, `package.json`) 64 | - Will export all the necessary Typescript files from the `public-api`. Necessary files are (`components`, `services` or other Typescript files expect `.spec` files) 65 | - Will update the `public-api` in the root level and export all subentries 66 | - Will adjust the paths of your `tsconfig.json` so that your IDEA understands subentris 67 | 68 | ### Prerequisit for a successfull split 69 | 70 | For ng-samurai to function appropriately, there are certain requirements your library needs to fulfill. In some cases, you might need to refactor your application before using `ng-samurai`. 71 | 72 | #### Folder structure 73 | 74 | Converting your library to subentries may also require a change of the folder structure. Each module will result in a subentry and needs its folder. Subentries can not have multiple modules. 75 | 76 | _Valid file structure_ 77 | ![Logo](https://raw.githubusercontent.com/kreuzerk/ng-samurai/master/docs/prerequisite/file-structure-valid.png) 78 | 79 | _Invalid file structure_ 80 | ![Logo](https://raw.githubusercontent.com/kreuzerk/ng-samurai/master/docs/prerequisite/file-structure-invalid.png) 81 | 82 | #### Circular dependencies 83 | 84 | A subentry can use another subentry. But subentries can not work with circular dependencies. 85 | ![Logo](https://raw.githubusercontent.com/kreuzerk/ng-samurai/master/docs/prerequisite/circular-dependencies.png) 86 | 87 | #### Each file needs to belong to a module 88 | 89 | Entry points can contain all sorts of files. ng-samurai needs a module-file to be present to perform the migration. The .module file indicates to ng-samurai that this code will be split into a subentry. 90 | ![Logo](https://raw.githubusercontent.com/kreuzerk/ng-samurai/master/docs/prerequisite/module-required.png) 91 | 92 | ## Generate subentry 93 | 94 | Once your library is converted to subentries, it's likely that you want to add new subentries. To do so, you can run 95 | the following command: 96 | 97 | ``` 98 | ng g ng-samurai:generate-subentry 99 | ``` 100 | 101 | This will do the following things: 102 | 103 | - Will create a new folder with the provided name 104 | - Will create a (`module`, `component`, `index.ts`, `public-api.ts`, `package.json`) 105 | - Will export the module and the component from the `public-api.ts` 106 | 107 | # Further resources 108 | 109 | If the topic of subentries is new to you. The following resources explain subentries in 110 | more detail. 111 | 112 | - [ng-packagr subentry documentation](https://github.com/ng-packagr/ng-packagr/blob/master/docs/secondary-entrypoints.md) 113 | - [Blog on Angular in depth: Improve SPA performance by splliting your library in multiple chunks](https://medium.com/angular-in-depth/improve-spa-performance-by-splitting-your-angular-libraries-in-multiple-chunks-8c68103692d0) 114 | -------------------------------------------------------------------------------- /docs/logo/ng-samurai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivekcode/ng-samurai/71189e1d357cf56e1639f49bdb1e709ba47fdffe/docs/logo/ng-samurai.png -------------------------------------------------------------------------------- /docs/prerequisite/circular-dependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivekcode/ng-samurai/71189e1d357cf56e1639f49bdb1e709ba47fdffe/docs/prerequisite/circular-dependencies.png -------------------------------------------------------------------------------- /docs/prerequisite/file-structure-invalid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivekcode/ng-samurai/71189e1d357cf56e1639f49bdb1e709ba47fdffe/docs/prerequisite/file-structure-invalid.png -------------------------------------------------------------------------------- /docs/prerequisite/file-structure-valid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivekcode/ng-samurai/71189e1d357cf56e1639f49bdb1e709ba47fdffe/docs/prerequisite/file-structure-valid.png -------------------------------------------------------------------------------- /docs/prerequisite/module-required.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivekcode/ng-samurai/71189e1d357cf56e1639f49bdb1e709ba47fdffe/docs/prerequisite/module-required.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-samurai", 3 | "version": "0.0.0-development", 4 | "description": "A blank schematics", 5 | "scripts": { 6 | "build": "tsc -p tsconfig.json", 7 | "build:watch": "tsc -p tsconfig.json --watch", 8 | "format": "prettier src/**/*.{ts,json,md} --write", 9 | "format:test": "prettier src/**/*.{ts,json,md} --list-different", 10 | "test": "jest --collectCoverage", 11 | "test:watch": "jest --watch" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "pretty-quick --staged" 16 | } 17 | }, 18 | "keywords": [ 19 | "schematics" 20 | ], 21 | "author": "Kevin Kreuzer", 22 | "license": "MIT", 23 | "schematics": "./src/collection.json", 24 | "dependencies": { 25 | "@angular-devkit/core": "^12.2.3", 26 | "@angular-devkit/schematics": "^12.2.3", 27 | "@schematics/angular": "^12.2.3", 28 | "boxen": "^4.2.0", 29 | "chalk": "^4.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^25.2.1", 33 | "@types/node": "^8.10.59", 34 | "husky": "^4.2.5", 35 | "jest": "^25.5.2", 36 | "prettier": "^1.19.1", 37 | "pretty-quick": "^2.0.1", 38 | "semantic-release": "^17.0.7", 39 | "ts-jest": "^25.4.0", 40 | "ts-node": "^8.6.2", 41 | "typescript": "^4.4.2" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/kreuzerk/ng-samurai.git" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json", 3 | "schematics": { 4 | "split-lib": { 5 | "description": "Automatically split your library into multiple chungks", 6 | "factory": "./library-split/index#splitLib" 7 | }, 8 | "generate-subentry": { 9 | "description": "A schematic to generate tree-shakeable sub-entry in Angular library (ng-packagr secondary entry point).", 10 | "factory": "./subentry/index#generateSubentry", 11 | "schema": "./subentry/schema.json" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/library-split/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; 3 | import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; 4 | import { Schema as LibraryOptions } from '@schematics/angular/library/schema'; 5 | import { Schema as ModuleOptions } from '@schematics/angular/module/schema'; 6 | import { Schema as ComponentOptions } from '@schematics/angular/component/schema'; 7 | import { Schema as ServiceOptions } from '@schematics/angular/service/schema'; 8 | 9 | const workspaceOptions: WorkspaceOptions = { 10 | name: 'some-workspace', 11 | newProjectRoot: 'projects', 12 | version: '8.0.0' 13 | }; 14 | 15 | const libOptions: LibraryOptions = { 16 | name: 'some-lib' 17 | }; 18 | 19 | const collectionPath = path.join(__dirname, '../collection.json'); 20 | const runner = new SchematicTestRunner('schematics', collectionPath); 21 | let appTree: UnitTestTree; 22 | 23 | describe('split', () => { 24 | beforeEach(async () => { 25 | console.log = () => {}; 26 | 27 | appTree = await runner 28 | .runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions) 29 | .toPromise(); 30 | 31 | appTree = await runner 32 | .runExternalSchematicAsync('@schematics/angular', 'library', libOptions, appTree) 33 | .toPromise(); 34 | removeDefaultLibraryModule(); 35 | 36 | await generateModuleAndComponent('foo'); 37 | 38 | const fooServiceOptions: ServiceOptions = { 39 | name: 'foo', 40 | project: 'some-lib', 41 | path: 'projects/some-lib/src/lib/foo' 42 | }; 43 | appTree = await runner 44 | .runExternalSchematicAsync('@schematics/angular', 'service', fooServiceOptions, appTree) 45 | .toPromise(); 46 | 47 | await generateModuleAndComponent('bar'); 48 | const fooComponentOptions: ComponentOptions = { 49 | name: 'baz', 50 | path: 'projects/some-lib/src/lib/bar', 51 | module: 'bar', 52 | project: 'some-lib' 53 | }; 54 | appTree = await runner 55 | .runExternalSchematicAsync('@schematics/angular', 'component', fooComponentOptions, appTree) 56 | .toPromise(); 57 | 58 | appTree.create( 59 | 'projects/some-lib/src/lib/bar/bar.model.ts', 60 | ` 61 | export interface Bar { 62 | foo: string; 63 | baz: string; 64 | } 65 | ` 66 | ); 67 | }); 68 | 69 | async function generateModuleAndComponent(name: string) { 70 | const fooModuleOptions: ModuleOptions = { name, project: 'some-lib' }; 71 | appTree = await runner 72 | .runExternalSchematicAsync('@schematics/angular', 'module', fooModuleOptions, appTree) 73 | .toPromise(); 74 | 75 | const fooComponentOptions: ComponentOptions = { 76 | name, 77 | module: 'foo', 78 | project: 'some-lib' 79 | }; 80 | appTree = await runner 81 | .runExternalSchematicAsync('@schematics/angular', 'component', fooComponentOptions, appTree) 82 | .toPromise(); 83 | } 84 | 85 | function removeDefaultLibraryModule() { 86 | appTree.delete('/projects/some-lib/src/lib/some-lib.module.ts'); 87 | appTree.delete('/projects/some-lib/src/lib/some-lib.component.spec.ts'); 88 | appTree.delete('/projects/some-lib/src/lib/some-lib.component.ts'); 89 | appTree.delete('/projects/some-lib/src/lib/some-lib.service.ts'); 90 | appTree.delete('/projects/some-lib/src/lib/some-lib.service.spec.ts'); 91 | } 92 | 93 | describe('public-api', () => { 94 | describe('public-api top level', () => { 95 | function expectedPublicAPIContent(fileNames: string[]): string { 96 | let result = ''; 97 | fileNames.forEach((fileName: string) => { 98 | result += `export * from '${fileName}';\n`; 99 | }); 100 | return result; 101 | } 102 | 103 | it('should export foo and bar from the public-api', async () => { 104 | const updatedTree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 105 | const topLevelPublicAPIContent = updatedTree.readContent( 106 | '/projects/some-lib/src/public-api.ts' 107 | ); 108 | const expectedTopLevelPublicAPIContent = expectedPublicAPIContent([ 109 | 'some-lib/src/lib/foo', 110 | 'some-lib/src/lib/bar' 111 | ]); 112 | 113 | expect(topLevelPublicAPIContent).toEqual(expectedTopLevelPublicAPIContent); 114 | }); 115 | }); 116 | 117 | describe('public_api subentry', () => { 118 | function expectedSubentryPublicAPIContent(fileNames: string[]): string { 119 | let result = ''; 120 | fileNames.forEach((fileName: string) => { 121 | result += `export * from './${fileName}';\n`; 122 | }); 123 | return result; 124 | } 125 | 126 | it('should add a public_api to foo module', async () => { 127 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 128 | expect(tree.exists('/projects/some-lib/src/lib/foo/public-api.ts')).toBe(true); 129 | }); 130 | 131 | it('should add a public_api to bar module', async () => { 132 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 133 | expect(tree.exists('/projects/some-lib/src/lib/bar/public-api.ts')).toBe(true); 134 | }); 135 | 136 | it('should not add a public_api to baz module', async () => { 137 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 138 | expect(tree.exists('/projects/some-lib/src/lib/bar/baz/public-api.ts')).not.toBe(true); 139 | }); 140 | 141 | it('should export foo.component.ts and foo.module.ts from foos public-api', async () => { 142 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 143 | const publicAPI = tree.read('/projects/some-lib/src/lib/foo/public-api.ts').toString(); 144 | const expectedFilesIncludedInPublicAPI = ['foo.module', 'foo.component', 'foo.service']; 145 | const expectedFileContent = expectedSubentryPublicAPIContent( 146 | expectedFilesIncludedInPublicAPI 147 | ); 148 | 149 | expect(publicAPI).toEqual(expectedFileContent); 150 | }); 151 | 152 | it('should export bar.component.ts, bar.module.ts, bar.model and baz.component.ts from bars public-api', async () => { 153 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 154 | const publicAPI = tree.read('/projects/some-lib/src/lib/bar/public-api.ts').toString(); 155 | const expectedFilesIncludedInPublicAPI = [ 156 | 'bar.module', 157 | 'bar.component', 158 | 'bar.model', 159 | 'baz/baz.component' 160 | ]; 161 | const expectedFileContent = expectedSubentryPublicAPIContent( 162 | expectedFilesIncludedInPublicAPI 163 | ); 164 | 165 | expect(publicAPI).toEqual(expectedFileContent); 166 | }); 167 | }); 168 | }); 169 | 170 | describe('index.ts', () => { 171 | it('should add an index.ts to foo module', async () => { 172 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 173 | expect(tree.exists('/projects/some-lib/src/lib/foo/index.ts')).toBe(true); 174 | }); 175 | 176 | it('should add export everything from public-api inside the index.ts of foo', async () => { 177 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 178 | expect(tree.read('/projects/some-lib/src/lib/foo/index.ts').toString()).toEqual( 179 | "export * from './public-api';\n" 180 | ); 181 | }); 182 | 183 | it('should add an index.ts bar module', async () => { 184 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 185 | expect(tree.exists('/projects/some-lib/src/lib/bar/index.ts')).toBe(true); 186 | }); 187 | 188 | it('should add export everything from public-api inside the index.ts of bar', async () => { 189 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 190 | expect(tree.read('/projects/some-lib/src/lib/bar/index.ts').toString()).toEqual( 191 | "export * from './public-api';\n" 192 | ); 193 | }); 194 | 195 | it('should not add an index.ts to baz module', async () => { 196 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 197 | expect(tree.exists('/projects/some-lib/src/lib/bar/baz/index.ts')).not.toBe(true); 198 | }); 199 | }); 200 | 201 | describe('package.json', () => { 202 | it('should add an index.ts to foo module', async () => { 203 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 204 | expect(tree.exists('/projects/some-lib/src/lib/foo/package.json')).toBe(true); 205 | }); 206 | 207 | it('should add the correct config to the package.json of foo subentry', async () => { 208 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 209 | const expectedSubentryConfig = { 210 | ngPackage: { 211 | lib: { 212 | entryFile: 'public-api.ts', 213 | cssUrl: 'inline' 214 | } 215 | } 216 | }; 217 | const subEntryConfig = JSON.parse( 218 | tree.read('/projects/some-lib/src/lib/foo/package.json').toString() 219 | ); 220 | expect(subEntryConfig).toEqual(expectedSubentryConfig); 221 | }); 222 | 223 | it('should add an packag.json to bar module', async () => { 224 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 225 | expect(tree.exists('/projects/some-lib/src/lib/bar/package.json')).toBe(true); 226 | }); 227 | 228 | it('should add the correct config to the package.json of bar subentry', async () => { 229 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 230 | const expectedSubentryConfig = { 231 | ngPackage: { 232 | lib: { 233 | entryFile: 'public-api.ts', 234 | cssUrl: 'inline' 235 | } 236 | } 237 | }; 238 | const subEntryConfig = JSON.parse( 239 | tree.read('/projects/some-lib/src/lib/bar/package.json').toString() 240 | ); 241 | expect(subEntryConfig).toEqual(expectedSubentryConfig); 242 | }); 243 | 244 | it('should not add a package.json to baz module', async () => { 245 | const tree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 246 | expect(tree.exists('/projects/some-lib/src/lib/bar/baz/package.json')).not.toBe(true); 247 | }); 248 | }); 249 | 250 | describe('paths', () => { 251 | function updateBarModuleContent() { 252 | const barModuleFilePath = '/projects/some-lib/src/lib/bar/bar.module.ts'; 253 | const importStatementToAdd = `import {FooModule} from '../foo/foo.module.ts';`; 254 | const barModuleFileContent = appTree.readContent(barModuleFilePath); 255 | appTree.overwrite(barModuleFilePath, `${importStatementToAdd}\n${barModuleFileContent}`); 256 | } 257 | 258 | function getExpectedBarModuleContent() { 259 | const barModuleFilePath = '/projects/some-lib/src/lib/bar/bar.module.ts'; 260 | const expectedChangedImportPath = `import {FooModule} from 'some-lib/src/lib/foo';`; 261 | const barModuleFileContent = appTree.readContent(barModuleFilePath); 262 | return `${expectedChangedImportPath}\n${barModuleFileContent}`; 263 | } 264 | 265 | function updateBazComponentContent() { 266 | const bazComponentFilePath = '/projects/some-lib/src/lib/bar/baz/baz.component.ts'; 267 | const importStatementToAdd = `import {BarModel} from '../bar/bar.model.ts';`; 268 | const bazComponentFileContent = appTree.readContent(bazComponentFilePath); 269 | appTree.overwrite( 270 | bazComponentFilePath, 271 | `${importStatementToAdd}\n${bazComponentFileContent}` 272 | ); 273 | } 274 | 275 | function getExpectedBazComponentContent() { 276 | const bazComponentFilePath = '/projects/some-lib/src/lib/bar/baz/baz.component.ts'; 277 | const importStatementToAdd = `import {BarModel} from '../bar/bar.model.ts';`; 278 | const bazComponentFileContent = appTree.readContent(bazComponentFilePath); 279 | return `${importStatementToAdd}\n${bazComponentFileContent}`; 280 | } 281 | 282 | it(`should adjust the paths to other modules but not the third pary imports and not the imports from 283 | the same folder`, async () => { 284 | // needs to be called before we update the module file content 285 | const expectedModuleContent = getExpectedBarModuleContent(); 286 | updateBarModuleContent(); 287 | 288 | const updatedTree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 289 | const moduleContentAfterSchematics = updatedTree.readContent( 290 | '/projects/some-lib/src/lib/bar/bar.module.ts' 291 | ); 292 | 293 | expect(moduleContentAfterSchematics).toEqual(expectedModuleContent); 294 | }); 295 | 296 | it('should not update the baz components content since the import paths do not need to be updated', async () => { 297 | // needs to be called before we update the module file content 298 | const expectedComponentContent = getExpectedBazComponentContent(); 299 | updateBazComponentContent(); 300 | 301 | const updatedTree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 302 | const componentContentAfterSchematics = updatedTree.readContent( 303 | '/projects/some-lib/src/lib/bar/baz/baz.component.ts' 304 | ); 305 | 306 | expect(componentContentAfterSchematics).toEqual(expectedComponentContent); 307 | }); 308 | }); 309 | 310 | describe('tsconfig', () => { 311 | function deletePathsFromTsconfig() { 312 | const tsconfigContent = JSON.parse( 313 | appTree 314 | .read('tsconfig.json') 315 | .toString() 316 | .replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '') 317 | ); 318 | delete tsconfigContent.compilerOptions.paths; 319 | appTree.overwrite('tsconfig.json', JSON.stringify(tsconfigContent)); 320 | } 321 | 322 | it('should update the paths in the tsconfig.json', async () => { 323 | const updatedTree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 324 | const tsconfigContent = JSON.parse(updatedTree.readContent('tsconfig.json')); 325 | const expectedPaths = { 326 | 'some-lib': ['dist/some-lib/some-lib', 'dist/some-lib'], 327 | 'some-lib/*': ['projects/some-lib/*', 'projects/some-lib'] 328 | }; 329 | 330 | const paths = tsconfigContent.compilerOptions.paths; 331 | expect(paths).toEqual(expectedPaths); 332 | }); 333 | 334 | it('should add paths to the tsconfig.json even if no path exist', async () => { 335 | deletePathsFromTsconfig(); 336 | const updatedTree = await runner.runSchematicAsync('split-lib', {}, appTree).toPromise(); 337 | const tsconfigContent = JSON.parse(updatedTree.readContent('tsconfig.json')); 338 | const expectedPaths = { 339 | 'some-lib/*': ['projects/some-lib/*', 'projects/some-lib'] 340 | }; 341 | 342 | const paths = tsconfigContent.compilerOptions.paths; 343 | expect(paths).toEqual(expectedPaths); 344 | }); 345 | }); 346 | }); 347 | -------------------------------------------------------------------------------- /src/library-split/index.ts: -------------------------------------------------------------------------------- 1 | import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; 2 | 3 | import { generateSubentry } from '../subentry/index'; 4 | import { getFolderPath, getLibRootPath, getModuleName, resolvePath } from '../shared/path-helper'; 5 | import { addTsconfigPaths } from '../rules/add-tsconfig-paths.rule'; 6 | import { updateImportPaths } from '../rules/update-import-paths.rule'; 7 | import { updateSubentryPublicAPI } from '../rules/update-public-api/update-subentry-public-api.rule'; 8 | import { updateTopLevelPublicAPI } from '../rules/update-public-api/update-top-level-public-api.rule'; 9 | import { logWelcomeMessage } from '../shared/log-helper'; 10 | 11 | export function splitLib(_options: any): Rule { 12 | logWelcomeMessage(); 13 | 14 | return (tree: Tree, _context: SchematicContext) => { 15 | const libRootPath = getLibRootPath(tree); 16 | const rules: Rule[] = []; 17 | const modulePaths: string[] = []; 18 | 19 | tree.getDir(libRootPath).visit(filePath => { 20 | if (filePath.endsWith('.ts')) { 21 | rules.push(updateImportPaths(filePath)); 22 | } 23 | 24 | if (filePath.endsWith('module.ts')) { 25 | modulePaths.push(filePath); 26 | 27 | rules.push( 28 | generateSubentry({ 29 | name: getModuleName(filePath), 30 | filesPath: '../subentry/files', 31 | path: resolvePath(getFolderPath(filePath), '..'), 32 | generateComponent: false, 33 | generateModule: false 34 | }) 35 | ); 36 | rules.push(updateSubentryPublicAPI(filePath)); 37 | } 38 | }); 39 | rules.push(updateTopLevelPublicAPI(modulePaths)); 40 | rules.push(addTsconfigPaths()); 41 | return chain(rules); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/rules/add-tsconfig-paths.rule.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { WorkspaceProject, WorkspaceSchema } from '@schematics/angular/utility/workspace-models'; 3 | import { logError } from '../shared/log-helper'; 4 | 5 | export function addTsconfigPaths(): Rule { 6 | return (tree: Tree) => { 7 | try { 8 | // While reading the tsconfig, use a regex replacement https://www.regextester.com/94245 9 | // to ensure no **JSON comments** are present. Comments lead to a JSON.parse error. 10 | const tsconfig = JSON.parse( 11 | tree 12 | .read('tsconfig.json') 13 | .toString() 14 | .replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '') 15 | ); 16 | const angularJSON = JSON.parse(tree.read('angular.json').toString()); 17 | const libraryProjectNames = getLibraryProjectNames(angularJSON); 18 | 19 | if (!tsconfig.compilerOptions.paths) { 20 | tsconfig.compilerOptions.paths = {}; 21 | } 22 | 23 | libraryProjectNames.forEach((libraryProjectName: string) => { 24 | tsconfig.compilerOptions.paths[`${libraryProjectName}/*`] = [ 25 | `projects/${libraryProjectName}/*`, 26 | `projects/${libraryProjectName}` 27 | ]; 28 | }); 29 | 30 | tree.overwrite('tsconfig.json', JSON.stringify(tsconfig, null, 2)); 31 | } catch (e) { 32 | logError( 33 | `Something went wrong while ng-samurai tried to update your tsconfig.json, ${e.toString()}` 34 | ); 35 | } 36 | }; 37 | } 38 | 39 | function getLibraryProjectNames(angularJSON: WorkspaceSchema): string[] { 40 | return Object.values(angularJSON.projects) 41 | .filter((project: WorkspaceProject) => project.projectType === 'library') 42 | .map((project: WorkspaceProject) => project.root.split('/')[1]); 43 | } 44 | -------------------------------------------------------------------------------- /src/rules/update-import-paths.rule.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { Rule, Tree } from '@angular-devkit/schematics'; 3 | import { findModule } from '@schematics/angular/utility/find-module'; 4 | 5 | import { 6 | convertModulePathToPublicAPIImport, 7 | convertToAbsolutPath, 8 | getFolderPath 9 | } from '../shared/path-helper'; 10 | import { logError } from '../shared/log-helper'; 11 | 12 | interface Modification { 13 | startPosition: number; 14 | endPosition: number; 15 | content: string; 16 | } 17 | 18 | export function updateImportPaths(filePath: string): Rule { 19 | return (tree: Tree) => { 20 | let modifications = getImportPathModifications(tree, filePath); 21 | let source = tree.read(filePath).toString(); 22 | for (let modification of modifications.reverse()) { 23 | source = 24 | source.slice(0, modification.startPosition) + 25 | modification.content + 26 | source.slice(modification.endPosition); 27 | } 28 | tree.overwrite(filePath, source); 29 | return tree; 30 | }; 31 | } 32 | 33 | function getImportPathModifications(tree: Tree, filePath: string): Modification[] { 34 | const sourceCode = tree.read(filePath).toString(); 35 | const rootNode = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true); 36 | const modifications: Modification[] = []; 37 | const modulePathFileBelongsTo = findModule(tree, getFolderPath(filePath)); 38 | 39 | function updatePaths(node: ts.Node) { 40 | if (ts.isImportDeclaration(node)) { 41 | const importSegments = node.getChildren(); 42 | const importNode = importSegments.find( 43 | segment => segment.kind === ts.SyntaxKind.StringLiteral 44 | ); 45 | 46 | if ( 47 | importNode && 48 | !isThirdPartyLibImport(importNode) && 49 | importsForeignModuleCode(importNode, modulePathFileBelongsTo, filePath, tree) 50 | ) { 51 | const moduleFromImportPath = getModulePathFromImport(importNode.getText(), filePath, tree); 52 | modifications.push({ 53 | startPosition: importNode.pos + 1, 54 | endPosition: importNode.end + 1, 55 | content: `'${convertModulePathToPublicAPIImport(moduleFromImportPath)}';` 56 | }); 57 | } 58 | } 59 | } 60 | rootNode.forEachChild(updatePaths); 61 | return modifications; 62 | } 63 | 64 | function isThirdPartyLibImport(importNode: ts.Node): boolean { 65 | return !importNode.getText().startsWith(`'.`); 66 | } 67 | 68 | function importsForeignModuleCode( 69 | importNode: ts.Node, 70 | modulePathFileBelongsToPath: string, 71 | filePath: string, 72 | tree: Tree 73 | ): boolean { 74 | const importStringLiteral = importNode.getText(); 75 | return ( 76 | modulePathFileBelongsToPath !== getModulePathFromImport(importStringLiteral, filePath, tree) 77 | ); 78 | } 79 | 80 | function getModulePathFromImport(importLiteral: string, filePath: string, tree: Tree): string { 81 | try { 82 | return findModule(tree, convertToAbsolutPath(filePath, importLiteral)); 83 | } catch (e) { 84 | logError(`Could not find a module for the import path ${importLiteral} in ${filePath}. 85 | Please adjust the import path and rerun the schematics`); 86 | process.exit(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/rules/update-public-api/update-public-api.rule.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { logError } from '../../shared/log-helper'; 3 | 4 | export function updatePublicAPI(path: string, paths: string[]): Rule { 5 | return (tree: Tree) => { 6 | try { 7 | const publicAPIFile = path + '/public-api.ts'; 8 | tree.overwrite(publicAPIFile, generatePublicAPIcontent(paths)); 9 | } catch (e) { 10 | console.error(e); 11 | logError(`Something went wrong: Do you have multiple modules in ${path}`); 12 | } 13 | }; 14 | } 15 | 16 | export function generatePublicAPIcontent(paths: string[]): string { 17 | let result = ''; 18 | paths.forEach((path: string) => { 19 | if (!path.includes('spec.ts')) { 20 | result += `export * from '${path.split('.ts')[0]}';\n`; 21 | } 22 | }); 23 | return result; 24 | } 25 | -------------------------------------------------------------------------------- /src/rules/update-public-api/update-subentry-public-api.rule.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { buildRelativePath } from '@schematics/angular/utility/find-module'; 3 | 4 | import { getFileDirectoryPath } from '../../shared/path-helper'; 5 | 6 | import { updatePublicAPI } from './update-public-api.rule'; 7 | 8 | export function updateSubentryPublicAPI(filePath: string): Rule { 9 | return (tree: Tree) => { 10 | const directoryPath = getFileDirectoryPath(filePath); 11 | const relativeFilePaths = getRelativePathsToFilesInDirectory(tree, filePath); 12 | return updatePublicAPI(directoryPath, relativeFilePaths); 13 | }; 14 | } 15 | 16 | function getRelativePathsToFilesInDirectory(tree: Tree, filePath: string) { 17 | const relativeFilePaths: string[] = []; 18 | tree.getDir(getFileDirectoryPath(filePath)).visit((fileName: string) => { 19 | if (needsToBeExported(fileName)) { 20 | relativeFilePaths.push(buildRelativePath(filePath, fileName)); 21 | } 22 | }); 23 | return relativeFilePaths; 24 | } 25 | 26 | function needsToBeExported(fileName: string): boolean { 27 | return ( 28 | fileName.endsWith('ts') && !fileName.endsWith('index.ts') && !fileName.endsWith('public-api.ts') 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/rules/update-public-api/update-top-level-public-api.rule.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | 3 | import { convertModulePathToPublicAPIImport, getSourceRootPath } from '../../shared/path-helper'; 4 | 5 | import { updatePublicAPI } from './update-public-api.rule'; 6 | 7 | export function updateTopLevelPublicAPI(modulePaths: string[]): Rule { 8 | return (tree: Tree) => { 9 | const sourceRootPath = getSourceRootPath(tree); 10 | const publicAPIPaths = modulePaths.map((modulePath: string) => 11 | convertModulePathToPublicAPIImport(modulePath) 12 | ); 13 | return updatePublicAPI(sourceRootPath, publicAPIPaths); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/log-helper.ts: -------------------------------------------------------------------------------- 1 | import * as chalk from 'chalk'; 2 | import * as boxen from 'boxen'; 3 | 4 | export function logWelcomeMessage() { 5 | console.log( 6 | boxen( 7 | ` 8 | _ __ __ _ ___ __ _ _ __ ___ _ _ _ __ __ _(_) 9 | | '_ \\ / _\` |_____/ __|/ _\` | '_ \` _ \\| | | | '__/ _\` | | 10 | | | | | (_| |_____\\__ \\ (_| | | | | | | |_| | | | (_| | | 11 | |_| |_|\\__, | |___/\\__,_|_| |_| |_|\\__,_|_| \\__,_|_| 12 | |___/ 13 | ` + 14 | chalk.blue( 15 | ` 16 | /\\ 17 | /vvvvvvvvvvvv \\--------------------------------------, 18 | \`^^^^^^^^^^^^ /=====================================" 19 | \\/ 20 | ` 21 | ), 22 | { padding: 2, borderColor: 'blue' } 23 | ) 24 | ); 25 | } 26 | 27 | export function logError(error: string) { 28 | console.log(`${chalk.blue('Ng-samurai: ')} ${chalk.red(error)}`); 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/path-helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertModulePathToPublicAPIImport, 3 | convertToAbsolutPath, 4 | getFileDirectoryPath, 5 | getFolderPath, 6 | getLibRootPath, 7 | getModuleName, 8 | getSourceRootPath 9 | } from './path-helper'; 10 | 11 | describe('path-helper', () => { 12 | it('should get the directory of a filepath', () => { 13 | const filepath = './foo/bar/baz.component.ts'; 14 | const expectedDirectoryPath = './foo/bar'; 15 | 16 | expect(getFileDirectoryPath(filepath)).toEqual(expectedDirectoryPath); 17 | }); 18 | 19 | it('should get the name of a module from a modules file path', () => { 20 | const moduleFilePath = './foo/bar/bar.module.ts'; 21 | const expectedModuleName = 'bar'; 22 | 23 | expect(getModuleName(moduleFilePath)).toEqual(expectedModuleName); 24 | }); 25 | 26 | it('should convert the modulePaths to a public API import path', () => { 27 | const moduleFilePath = '/projects/got-wiki/src/lib/arya-stark/arya-stark.module.ts'; 28 | const expectedPath = 'got-wiki/src/lib/arya-stark'; 29 | 30 | expect(convertModulePathToPublicAPIImport(moduleFilePath)).toEqual(expectedPath); 31 | }); 32 | 33 | it('should get the root path of the default project', () => { 34 | const workspace = { 35 | projects: { 36 | foo: { 37 | projectType: 'library', 38 | sourceRoot: 'projects/foo/src' 39 | } 40 | }, 41 | defaultProject: 'foo' 42 | }; 43 | const tree = { 44 | read: (): any => Buffer.from(JSON.stringify(workspace)) 45 | } as any; 46 | expect(getLibRootPath(tree)).toEqual('projects/foo/src/lib'); 47 | }); 48 | 49 | it('should return the library path of the desired project', () => { 50 | const workspace = { 51 | projects: { 52 | foo: { 53 | projectType: 'library', 54 | sourceRoot: 'projects/foo/src' 55 | }, 56 | bar: { 57 | projectType: 'library', 58 | sourceRoot: 'projects/bar/src' 59 | } 60 | }, 61 | defaultProject: 'foo' 62 | }; 63 | const tree = { 64 | read: (): any => Buffer.from(JSON.stringify(workspace)) 65 | } as any; 66 | expect(getLibRootPath(tree, 'bar')).toEqual('projects/bar/src/lib'); 67 | }); 68 | 69 | it('should get the foldre path of a file', () => { 70 | const filePath = './projects/foo/foo.component.ts'; 71 | const folderPath = './projects/foo'; 72 | 73 | expect(getFolderPath(filePath)).toEqual(folderPath); 74 | }); 75 | 76 | describe('getSourceRootPath', () => { 77 | it('should throw an exception if there is no angular.json', () => { 78 | const tree = { 79 | read: (): any => null 80 | } as any; 81 | expect(() => getSourceRootPath(tree)).toThrowError('Not and Angular CLI workspace'); 82 | }); 83 | 84 | it('should throw if the projectType is not a library', () => { 85 | const workspace = { 86 | projects: { 87 | foo: { 88 | projectType: 'application' 89 | } 90 | }, 91 | defaultProject: 'foo' 92 | }; 93 | const tree = { 94 | read: (): any => Buffer.from(JSON.stringify(workspace)) 95 | } as any; 96 | expect(() => getSourceRootPath(tree)).toThrowError( 97 | 'Ng-samurai works only for the "library" projects, please specify correct project using --project flag' 98 | ); 99 | }); 100 | 101 | it('should throw if the desired projectType is not a library', () => { 102 | const workspace = { 103 | projects: { 104 | foo: { 105 | projectType: 'library' 106 | }, 107 | bar: { 108 | projectType: 'application' 109 | } 110 | }, 111 | defaultProject: 'foo' 112 | }; 113 | const tree = { 114 | read: (): any => Buffer.from(JSON.stringify(workspace)) 115 | } as any; 116 | expect(() => getSourceRootPath(tree, 'bar')).toThrowError( 117 | 'Ng-samurai works only for the "library" projects, please specify correct project using --project flag' 118 | ); 119 | }); 120 | 121 | it('should return the source root of the default project', () => { 122 | const workspace = { 123 | projects: { 124 | foo: { 125 | projectType: 'library', 126 | sourceRoot: 'projects/foo/src' 127 | } 128 | }, 129 | defaultProject: 'foo' 130 | }; 131 | const tree = { 132 | read: (): any => Buffer.from(JSON.stringify(workspace)) 133 | } as any; 134 | expect(getSourceRootPath(tree)).toEqual('projects/foo/src'); 135 | }); 136 | 137 | it('should return the source root of the desired project', () => { 138 | const workspace = { 139 | projects: { 140 | foo: { 141 | projectType: 'library', 142 | sourceRoot: 'projects/foo/src' 143 | }, 144 | bar: { 145 | projectType: 'library', 146 | sourceRoot: 'projects/bar/src' 147 | } 148 | }, 149 | defaultProject: 'foo' 150 | }; 151 | const tree = { 152 | read: (): any => Buffer.from(JSON.stringify(workspace)) 153 | } as any; 154 | expect(getSourceRootPath(tree, 'bar')).toEqual('projects/bar/src'); 155 | }); 156 | }); 157 | 158 | it('should convert a import string litereal to an absolute path', () => { 159 | const filePath = './projects/foo/bar/baz/baz.component.ts'; 160 | const importStringLiteral = '../../foo.component.ts'; 161 | const expectedPath = './projects/foo/foo.component.ts'; 162 | 163 | expect(convertToAbsolutPath(filePath, importStringLiteral)).toEqual(expectedPath); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/shared/path-helper.ts: -------------------------------------------------------------------------------- 1 | import { SchematicsException, Tree } from '@angular-devkit/schematics'; 2 | 3 | export const getFileDirectoryPath = (filePath: string) => 4 | filePath.substring(0, filePath.lastIndexOf('/')); 5 | 6 | export function getModuleName(moduleFilePath: string): string { 7 | const pathSegments = moduleFilePath.split('/'); 8 | // the name is always the second last pathSegment 9 | return pathSegments[pathSegments.length - 2]; 10 | } 11 | 12 | export function convertModulePathToPublicAPIImport(modulePath: string): string { 13 | const regex = /\/projects\/(.*)(\/)/; 14 | const pathSegments = regex.exec(modulePath); 15 | return pathSegments && pathSegments.length ? pathSegments[1] : ''; 16 | } 17 | 18 | export function getSourceRootPath(tree: Tree, projectName?: string): string { 19 | const workspaceAsBuffer = tree.read('angular.json'); 20 | if (!workspaceAsBuffer) { 21 | throw new SchematicsException('Not and Angular CLI workspace'); 22 | } 23 | 24 | const workspace = JSON.parse(workspaceAsBuffer.toString()); 25 | const project = workspace.projects[projectName || workspace.defaultProject]; 26 | 27 | if (project.projectType === 'application') { 28 | throw new SchematicsException( 29 | 'Ng-samurai works only for the "library" projects, please specify correct project using --project flag' 30 | ); 31 | } 32 | return project.sourceRoot; 33 | } 34 | 35 | export function getLibRootPath(tree: Tree, projectName?: string): string { 36 | return `${getSourceRootPath(tree, projectName)}/lib`; 37 | } 38 | 39 | export function getFolderPath(filePath: string): string { 40 | return filePath.substring(0, filePath.lastIndexOf('/')); 41 | } 42 | 43 | export function convertToAbsolutPath(filePath: string, importStringLiteral: string): string { 44 | const levelsUp = getLevels(importStringLiteral); 45 | const filePathSegments = filePath.split('/'); 46 | const folderPathAfterLevelsMove = filePathSegments 47 | .slice(0, filePathSegments.length - levelsUp - 1) 48 | .join('/'); 49 | const pathAfterRelativeSegment = importStringLiteral.match(/\/[a-zA-Z0-9].*/); 50 | return `${folderPathAfterLevelsMove}${pathAfterRelativeSegment}`; 51 | } 52 | 53 | export function resolvePath(filePath: string, pathChange: string): string { 54 | const levelsUp = getLevels(pathChange); 55 | const filePathSegments = filePath.split('/'); 56 | return filePathSegments.slice(0, filePathSegments.length - levelsUp).join('/'); 57 | } 58 | 59 | function getLevels(importStringLiteral: string): number { 60 | const numberOfDots = importStringLiteral.match(/[^a-zA-Z0-9]*/)[0].match(/\./g)?.length; 61 | return Math.floor(numberOfDots / 2); 62 | } 63 | -------------------------------------------------------------------------------- /src/subentry/files/__name@dasherize__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public-api'; 2 | -------------------------------------------------------------------------------- /src/subentry/files/__name@dasherize__/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "ngPackage": { 3 | "lib": { 4 | "entryFile": "public-api.ts", 5 | "cssUrl": "inline" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/subentry/files/__name@dasherize__/public-api.ts: -------------------------------------------------------------------------------- 1 | <% if (generateModule) { %>export * from './<%= dasherize(name) %>.module';<% } %> 2 | <% if (generateComponent) { %>export * from './<%= dasherize(name) %>.component';<% } %> 3 | -------------------------------------------------------------------------------- /src/subentry/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; 3 | import { Style, Schema as ApplicationOptions } from '@schematics/angular/application/schema'; 4 | import { Schema as LibraryOptions } from '@schematics/angular/library/schema'; 5 | import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; 6 | 7 | const workspaceOptions: WorkspaceOptions = { 8 | name: 'some-workspace', 9 | newProjectRoot: 'projects', 10 | version: '8.0.0' 11 | }; 12 | 13 | const appOptions: ApplicationOptions = { 14 | name: 'some-app', 15 | inlineStyle: false, 16 | inlineTemplate: false, 17 | routing: false, 18 | style: Style.Css, 19 | skipTests: false, 20 | skipPackageJson: false 21 | }; 22 | 23 | const libOptions: LibraryOptions = { 24 | name: 'some-lib' 25 | }; 26 | 27 | const defaultOptions: any = { 28 | name: 'path/to/customer' 29 | }; 30 | 31 | const collectionPath = path.join(__dirname, '../collection.json'); 32 | const runner = new SchematicTestRunner('schematics', collectionPath); 33 | 34 | let appTree: UnitTestTree; 35 | 36 | describe('generate-subentry', () => { 37 | beforeEach(async () => { 38 | appTree = await runner 39 | .runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions) 40 | .toPromise(); 41 | appTree = await runner 42 | .runExternalSchematicAsync('@schematics/angular', 'library', libOptions, appTree) 43 | .toPromise(); 44 | appTree = await runner 45 | .runExternalSchematicAsync('@schematics/angular', 'application', appOptions, appTree) 46 | .toPromise(); 47 | }); 48 | 49 | it('should generate a CustomerComponent', async () => { 50 | const options = { ...defaultOptions }; 51 | 52 | const tree = await runner.runSchematicAsync('generate-subentry', options, appTree).toPromise(); 53 | 54 | expect( 55 | tree.files.includes('/projects/some-lib/src/lib/path/to/customer/customer.component.ts') 56 | ).toBe(true); 57 | }); 58 | 59 | it('should generate a CustomerModule and add a CustomerComponent', async () => { 60 | const options = { ...defaultOptions }; 61 | 62 | const tree = await runner.runSchematicAsync('generate-subentry', options, appTree).toPromise(); 63 | 64 | expect( 65 | tree.files.includes('/projects/some-lib/src/lib/path/to/customer/customer.module.ts') 66 | ).toBe(true); 67 | expect( 68 | tree 69 | .readContent('/projects/some-lib/src/lib/path/to/customer/customer.module.ts') 70 | .includes('CustomerComponent') 71 | ).toBe(true); 72 | }); 73 | 74 | it('should export everything from public-api inside index.ts', async () => { 75 | const options = { ...defaultOptions }; 76 | const expectedContent = "export * from './public-api';\n"; 77 | 78 | const tree = await runner.runSchematicAsync('generate-subentry', options, appTree).toPromise(); 79 | expect(tree.readContent('/projects/some-lib/src/lib/path/to/customer/index.ts')).toEqual( 80 | expectedContent 81 | ); 82 | }); 83 | 84 | it('should export the CustomerComponent and the CustomerModule from public-api', async () => { 85 | const options = { ...defaultOptions }; 86 | const expectedContent = 87 | "export * from './customer.module';\nexport * from './customer.component';\n"; 88 | 89 | const tree = await runner.runSchematicAsync('generate-subentry', options, appTree).toPromise(); 90 | expect(tree.readContent('/projects/some-lib/src/lib/path/to/customer/public-api.ts')).toEqual( 91 | expectedContent 92 | ); 93 | }); 94 | 95 | it('should add a package.json with the generate-subentry config', async () => { 96 | const options = { ...defaultOptions }; 97 | const expectedContent = { 98 | ngPackage: { 99 | lib: { 100 | entryFile: 'public-api.ts', 101 | cssUrl: 'inline' 102 | } 103 | } 104 | }; 105 | 106 | const tree = await runner.runSchematicAsync('generate-subentry', options, appTree).toPromise(); 107 | expect( 108 | JSON.parse(tree.readContent('/projects/some-lib/src/lib/path/to/customer/package.json')) 109 | ).toEqual(expectedContent); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/subentry/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | url, 3 | move, 4 | apply, 5 | chain, 6 | template, 7 | mergeWith, 8 | externalSchematic, 9 | Tree, 10 | Rule, 11 | SchematicContext, 12 | SchematicsException 13 | } from '@angular-devkit/schematics'; 14 | import { strings } from '@angular-devkit/core'; 15 | import { parseName } from '@schematics/angular/utility/parse-name'; 16 | 17 | import { Schema as SubentryOptions } from './schema.model'; 18 | 19 | export function generateSubentry(_options: SubentryOptions): Rule { 20 | return (tree: Tree, _context: SchematicContext) => { 21 | const moduleSchematicsOptions = { name: _options.name, path: _options.path }; 22 | const componentSchematicsOptions = { 23 | name: _options.name, 24 | path: _options.path, 25 | inlineStyle: _options.inlineStyle, 26 | inlineTemplate: _options.inlineTemplate, 27 | skipTests: _options.skipTests 28 | }; 29 | 30 | const workspaceAsBuffer = tree.read('angular.json'); 31 | if (!workspaceAsBuffer) { 32 | throw new SchematicsException('Not and Angular CLI workspace'); 33 | } 34 | 35 | const workspace = JSON.parse(workspaceAsBuffer.toString()); 36 | 37 | const projectName = _options.project || workspace.defaultProject; 38 | const project = workspace.projects[projectName]; 39 | 40 | if (project.projectType === 'application') { 41 | throw new SchematicsException( 42 | 'The "generateSubentry" schematics works only for the "library" projects, please specify correct project using --project flag' 43 | ); 44 | } 45 | 46 | const path = _options.path || `${project.sourceRoot}/lib`; 47 | 48 | const parsed = parseName(path, _options.name); 49 | _options.name = parsed.name; 50 | const sourceTemplate = url(_options.filesPath || './files'); 51 | 52 | const sourceTemplateParametrized = apply(sourceTemplate, [ 53 | template({ 54 | ..._options, 55 | ...strings 56 | }), 57 | move(parsed.path) 58 | ]); 59 | 60 | const rules = [mergeWith(sourceTemplateParametrized)]; 61 | 62 | if (_options.generateModule || _options.generateComponent) { 63 | rules.push( 64 | externalSchematic('@schematics/angular', 'module', { 65 | ...moduleSchematicsOptions, 66 | project: projectName 67 | }) 68 | ); 69 | } 70 | 71 | if (_options.generateComponent) { 72 | rules.push( 73 | externalSchematic('@schematics/angular', 'component', { 74 | ...componentSchematicsOptions, 75 | project: projectName 76 | }) 77 | ); 78 | } 79 | return chain(rules); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/subentry/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "NgSamuraiSubentry", 4 | "title": "Angular SubModule Options Schema", 5 | "type": "object", 6 | "description": "Creates a new sub-module for Angular library in the given or default project.", 7 | 8 | "properties": { 9 | "name": { 10 | "type": "string", 11 | "description": "The name of the sub-module.", 12 | "$default": { 13 | "$source": "argv", 14 | "index": 0 15 | }, 16 | "x-prompt": "What name would you like to use for the sub-module?" 17 | }, 18 | "path": { 19 | "type": "string", 20 | "format": "path", 21 | "description": "The path at which to create the sub-module files, relative to the current workspace. Default is a folder with the same name as the sub-module in the project root.", 22 | "visible": false 23 | }, 24 | "project": { 25 | "type": "string", 26 | "description": "The name of the project.", 27 | "$default": { 28 | "$source": "projectName" 29 | } 30 | }, 31 | "generateComponent": { 32 | "alias": "gc", 33 | "type": "boolean", 34 | "description": "Should a default component be generated for a sub-module? (If yes, module will always be generated too)", 35 | "default": true 36 | }, 37 | "generateModule": { 38 | "alias": "gm", 39 | "type": "boolean", 40 | "description": "Should a default module be generated for a sub-module?", 41 | "default": true 42 | }, 43 | 44 | "style": { 45 | "description": "The file extension or preprocessor to use for component style files.", 46 | "type": "string", 47 | "default": "css", 48 | "enum": ["css", "scss", "sass", "less", "styl"] 49 | }, 50 | "inlineStyle": { 51 | "description": "When true, includes styles inline in the component.ts file. Only CSS styles can be included inline. By default, an external styles file is created and referenced in the component.ts file.", 52 | "type": "boolean", 53 | "default": false 54 | }, 55 | "inlineTemplate": { 56 | "description": "When true, includes template inline in the component.ts file. By default, an external template file is created and referenced in the component.ts file.", 57 | "type": "boolean", 58 | "default": false 59 | }, 60 | "skipTests": { 61 | "type": "boolean", 62 | "description": "When true, does not create \"spec.ts\" test files for the new component.", 63 | "default": false 64 | } 65 | }, 66 | "required": ["name"] 67 | } 68 | -------------------------------------------------------------------------------- /src/subentry/schema.model.ts: -------------------------------------------------------------------------------- 1 | export interface Schema { 2 | name: string; 3 | filesPath?: string; 4 | path?: string; 5 | project?: string; 6 | generateComponent?: boolean; 7 | generateModule?: boolean; 8 | style?: string; 9 | inlineStyle?: boolean; 10 | inlineTemplate?: boolean; 11 | skipTests?: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "tsconfig", 4 | "lib": ["es2018", "dom"], 5 | "declaration": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noEmitOnError": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "noUnusedParameters": false, 13 | "noUnusedLocals": false, 14 | "rootDir": "src/", 15 | "skipDefaultLibCheck": true, 16 | "skipLibCheck": true, 17 | "sourceMap": true, 18 | "strictNullChecks": false, 19 | "target": "es6", 20 | "types": ["jest", "node"] 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["src/*/files/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "array-type": false, 8 | "arrow-parens": false, 9 | "deprecation": { 10 | "severity": "warn" 11 | }, 12 | "import-blacklist": [ 13 | true, 14 | "rxjs/Rx" 15 | ], 16 | "interface-name": false, 17 | "max-classes-per-file": false, 18 | "member-access": false, 19 | "member-ordering": [ 20 | true, 21 | { 22 | "order": [ 23 | "static-field", 24 | "instance-field", 25 | "static-method", 26 | "instance-method" 27 | ] 28 | } 29 | ], 30 | "no-consecutive-blank-lines": false, 31 | "no-console": [ 32 | true, 33 | "debug", 34 | "info", 35 | "time", 36 | "timeEnd", 37 | "trace" 38 | ], 39 | "no-empty": false, 40 | "no-inferrable-types": [ 41 | true, 42 | "ignore-params" 43 | ], 44 | "no-non-null-assertion": true, 45 | "no-redundant-jsdoc": true, 46 | "no-switch-case-fall-through": true, 47 | "no-use-before-declare": true, 48 | "no-var-requires": false, 49 | "object-literal-key-quotes": [ 50 | true, 51 | "as-needed" 52 | ], 53 | "object-literal-sort-keys": false, 54 | "ordered-imports": false, 55 | "quotemark": [ 56 | true, 57 | "single" 58 | ], 59 | "trailing-comma": false, 60 | "no-output-on-prefix": true, 61 | "no-inputs-metadata-property": true, 62 | "no-outputs-metadata-property": true, 63 | "no-host-metadata-property": true, 64 | "no-input-rename": true, 65 | "no-output-rename": true, 66 | "use-lifecycle-interface": true, 67 | "use-pipe-transform-interface": true, 68 | "component-class-suffix": true, 69 | "directive-class-suffix": true 70 | } 71 | } 72 | --------------------------------------------------------------------------------