diff --git a/.docgeni/app/module.ts b/.docgeni/app/module.ts new file mode 100644 index 00000000..d5bb6008 --- /dev/null +++ b/.docgeni/app/module.ts @@ -0,0 +1,7 @@ +import { FormsModule } from '@angular/forms'; +import { myProviders } from './providers'; + +export default { + imports: [FormsModule], + providers: [...myProviders] +}; diff --git a/.docgeni/app/providers.ts b/.docgeni/app/providers.ts new file mode 100644 index 00000000..38655bd0 --- /dev/null +++ b/.docgeni/app/providers.ts @@ -0,0 +1 @@ +export const myProviders = []; diff --git a/packages/core/src/angular/site-builder.ts b/packages/core/src/angular/site-builder.ts index 214ea432..a4c5804e 100644 --- a/packages/core/src/angular/site-builder.ts +++ b/packages/core/src/angular/site-builder.ts @@ -4,12 +4,14 @@ import { toolkit } from '@docgeni/toolkit'; import { AngularCommandOptions, SiteProject } from './types'; import Handlebars from 'handlebars'; import { getSystemPath, HostWatchEventType, normalize, relative, resolve } from '../fs'; -import { of, from } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { createNgSourceFile } from '@docgeni/ngdoc'; import { ValidationError } from '../errors'; import semver from 'semver'; import { spawn } from 'child_process'; import { SITE_ASSETS_RELATIVE_PATH } from '../constants'; +import { NgModuleMetadata } from '../types/module'; +import { combineNgModuleMetadata } from '../ast-utils'; +import { NgSourceUpdater } from '../ng-source-updater'; interface CopyFile { from: string; @@ -43,6 +45,7 @@ export class SiteBuilder { public ngVersion: string; public enableIvy: boolean; private publicDirPath: string; + private srcAppDirPath: string; private siteProject: SiteProject; spawn = spawn; @@ -55,6 +58,7 @@ export class SiteBuilder { if (this.docgeni.config.publicDir) { this.publicDirPath = this.docgeni.paths.getAbsPath(this.docgeni.config.publicDir); } + this.srcAppDirPath = this.docgeni.paths.getAbsPath('.docgeni/app'); } public async build() { @@ -66,7 +70,9 @@ export class SiteBuilder { } else { await this.createSiteProject(); await this.syncPublic(); + await this.syncSrcApp(); this.watchPublic(); + this.watchSrcApp(); } } @@ -163,6 +169,14 @@ export class SiteBuilder { return false; } + private async srcAppDirExists() { + if (this.srcAppDirPath) { + const result = await this.docgeni.host.exists(this.srcAppDirPath); + return result; + } + return false; + } + private async syncPublic() { if (await this.publicDirExists()) { const assetsPath = resolve(this.publicDirPath, `assets`); @@ -179,6 +193,72 @@ export class SiteBuilder { } } + private async syncSrcApp() { + if (await this.srcAppDirExists()) { + await this.docgeni.host.copy(this.srcAppDirPath, resolve(this.siteProject.sourceRoot, 'app')); + await this.buildAppModule(); + } + } + + private async watchSrcApp() { + if (this.docgeni.watch && (await this.srcAppDirExists())) { + this.docgeni.host.watchAggregated(this.srcAppDirPath).subscribe(async events => { + for (const event of events) { + const distPath = event.path.replace(this.srcAppDirPath, resolve(this.siteProject.sourceRoot, 'app')); + if (event.type === HostWatchEventType.Deleted) { + await this.docgeni.host.delete(distPath); + } else { + await this.docgeni.host.copy(event.path, distPath); + } + if (event.path.includes(resolve(this.srcAppDirPath, 'module.ts'))) { + this.buildAppModule(); + } + } + }); + } + } + + private async buildAppModule() { + const modulePath = resolve(this.srcAppDirPath, './module.ts'); + if (await this.docgeni.host.pathExists(modulePath)) { + const moduleText = await this.docgeni.host.readFile(modulePath); + const ngSourceFile = createNgSourceFile(modulePath, moduleText); + const defaultExports = ngSourceFile.getDefaultExports() as NgModuleMetadata; + const defaultExportNode = ngSourceFile.getDefaultExportNode(); + if (defaultExportNode) { + const metadata = combineNgModuleMetadata(defaultExports, { + declarations: [], + imports: [ + 'BrowserModule', + 'BrowserAnimationsModule', + 'DocgeniTemplateModule', + 'RouterModule.forRoot([])', + ' ...IMPORT_MODULES' + ], + providers: ['...DOCGENI_SITE_PROVIDERS'], + bootstrap: ['RootComponent'] + }); + + const updater = new NgSourceUpdater(ngSourceFile); + updater.insertImports([ + { name: 'NgModule', moduleSpecifier: '@angular/core' }, + { name: 'RouterModule', moduleSpecifier: '@angular/router' }, + { name: 'BrowserModule', moduleSpecifier: '@angular/platform-browser' }, + { name: 'BrowserAnimationsModule', moduleSpecifier: '@angular/platform-browser/animations' }, + { name: 'DocgeniTemplateModule', moduleSpecifier: '@docgeni/template' }, + { name: 'DOCGENI_SITE_PROVIDERS', moduleSpecifier: './content/index' }, + { name: 'IMPORT_MODULES', moduleSpecifier: './content/index' }, + { name: 'RootComponent', moduleSpecifier: './content/index' } + ]); + updater.insertNgModule('AppModule', metadata); + updater.removeDefaultExport(); + + updater.update(); + await this.docgeni.host.writeFile(resolve(this.siteProject.sourceRoot, './app/app.module.ts'), updater.update()); + } + } + } + private async watchPublic() { if (this.docgeni.watch && (await this.publicDirExists())) { const assetsPath = resolve(this.publicDirPath, 'assets'); diff --git a/packages/core/src/angular/site-plugin.spec.ts b/packages/core/src/angular/site-plugin.spec.ts index 37b51ade..674f46dc 100644 --- a/packages/core/src/angular/site-plugin.spec.ts +++ b/packages/core/src/angular/site-plugin.spec.ts @@ -21,6 +21,7 @@ const SITE_TEMPLATE_PATH = resolve(__dirname, '../site-template'); const PUBLIC_PATH = `${DEFAULT_TEST_ROOT_PATH}/.docgeni/public`; const DEFAULT_SITE_PATH = `${DEFAULT_TEST_ROOT_PATH}/.docgeni/site`; +const SRC_APP_PATH = `${DEFAULT_TEST_ROOT_PATH}/.docgeni/app`; describe('#site-plugin', () => { let ngSitePlugin: AngularSitePlugin; @@ -39,7 +40,8 @@ describe('#site-plugin', () => { initialFiles: { [`${DEFAULT_TEST_ROOT_PATH}/node_modules/@angular/core/package.json`]: fixture.src['package.json'], [`${DEFAULT_TEST_ROOT_PATH}/angular.json`]: fixture.src['angular.json'], - [`${SITE_TEMPLATE_PATH}/src/main.ts`]: 'main.ts' + [`${SITE_TEMPLATE_PATH}/src/main.ts`]: 'main.ts', + [`${SITE_TEMPLATE_PATH}/src/app/app.module.ts`]: fixture.src['app/app.module.ts'] } }); ngSitePlugin = new AngularSitePlugin(); @@ -53,7 +55,8 @@ describe('#site-plugin', () => { { [`${DEFAULT_SITE_PATH}/angular.json`]: fixture.getOutputContent('angular.json'), [`${DEFAULT_SITE_PATH}/tsconfig.app.json`]: fixture.getOutputContent('tsconfig.app.json'), - [`${DEFAULT_SITE_PATH}/src/main.ts`]: 'main.ts' + [`${DEFAULT_SITE_PATH}/src/main.ts`]: 'main.ts', + [`${DEFAULT_SITE_PATH}/src/app/app.module.ts`]: fixture.getOutputContent('app/app.module.ts') }, true ); @@ -258,4 +261,107 @@ describe('#site-plugin', () => { await context.hooks.done.promise(); expect(calledSpawn).toEqual(true); }); + + describe('src/app', () => { + it('should generate new ng module and copy other source files in ".docgeni/app" dir', async () => { + const moduleText = `export default { providers: [ AClass ] }`; + await writeFilesToHost(context.host, { + [`${SRC_APP_PATH}/module.ts`]: moduleText, + [`${SRC_APP_PATH}/a.ts`]: 'const export a = "aaa"', + [`${SRC_APP_PATH}/sub/b.ts`]: 'const export b = "bbb"' + }); + await context.hooks.beforeRun.promise(); + await assertExpectedFiles( + context.host, + { + [`${DEFAULT_SITE_PATH}/src/app/a.ts`]: 'const export a = "aaa"', + [`${DEFAULT_SITE_PATH}/src/app/sub/b.ts`]: 'const export b = "bbb"' + }, + true + ); + const appModule = await context.host.readFile(`${DEFAULT_SITE_PATH}/src/app/app.module.ts`); + expect(appModule).toContain(`providers: [ AClass, ...DOCGENI_SITE_PROVIDERS ]`); + }); + + it('should copy new files when ".docgeni/app" dir files changed', async () => { + await writeFilesToHost(context.host, { + [`${SRC_APP_PATH}/a.ts`]: 'const export a = "aaa"', + [`${SRC_APP_PATH}/sub/b.ts`]: 'const export b = "bbb"' + }); + updateContext(context, { watch: true }); + const watchAggregatedSpy = spyOn(context.host, 'watchAggregated'); + const watchAggregated$ = new Subject(); + watchAggregatedSpy.and.callFake(files => { + return watchAggregated$.asObservable(); + }); + await context.hooks.beforeRun.promise(); + + await writeFilesToHost(context.host, { + [`${SRC_APP_PATH}/a.ts`]: 'const export a = "new"', + [`${SRC_APP_PATH}/c.ts`]: 'const export c = "ccc"' + }); + + watchAggregated$.next([ + { + type: HostWatchEventType.Created, + path: normalize(`${SRC_APP_PATH}/c.ts`), + time: new Date() + }, + { + type: HostWatchEventType.Changed, + path: normalize(`${SRC_APP_PATH}/a.ts`), + time: new Date() + }, + { + type: HostWatchEventType.Deleted, + path: normalize(`${SRC_APP_PATH}/sub/b.ts`), + time: new Date() + } + ]); + + await toolkit.utils.wait(2000); + expect(await context.host.exists(`${DEFAULT_SITE_PATH}/src/sub/b.ts`)).toBeFalsy(); + await assertExpectedFiles( + context.host, + { + [`${DEFAULT_SITE_PATH}/src/app/a.ts`]: 'const export a = "new"', + [`${DEFAULT_SITE_PATH}/src/app/c.ts`]: 'const export c = "ccc"' + }, + true + ); + }); + + it('should rebuild app module when module.ts changed', async () => { + const moduleText = `export default { providers: [ AClass ] }`; + await writeFilesToHost(context.host, { + [`${SRC_APP_PATH}/module.ts`]: moduleText + }); + updateContext(context, { watch: true }); + const watchAggregatedSpy = spyOn(context.host, 'watchAggregated'); + const watchAggregated$ = new Subject(); + watchAggregatedSpy.and.callFake(files => { + return watchAggregated$.asObservable(); + }); + await context.hooks.beforeRun.promise(); + const appModule = await context.host.readFile(resolve(DEFAULT_SITE_PATH, './src/app/app.module.ts')); + expect(appModule).toContain(`providers: [ AClass, ...DOCGENI_SITE_PROVIDERS ],`); + + const newModuleText = `export default { providers: [ NewClass ] }`; + await writeFilesToHost(context.host, { + [`${SRC_APP_PATH}/module.ts`]: newModuleText + }); + + watchAggregated$.next([ + { + type: HostWatchEventType.Changed, + path: normalize(`${SRC_APP_PATH}/module.ts`), + time: new Date() + } + ]); + + await toolkit.utils.wait(2000); + const newAppModule = await context.host.readFile(resolve(DEFAULT_SITE_PATH, './src/app/app.module.ts')); + expect(newAppModule).toContain(`providers: [ NewClass, ...DOCGENI_SITE_PROVIDERS ],`); + }); + }); }); diff --git a/packages/core/src/ast-utils.spec.ts b/packages/core/src/ast-utils.spec.ts index ce07e85f..17f8d7f5 100644 --- a/packages/core/src/ast-utils.spec.ts +++ b/packages/core/src/ast-utils.spec.ts @@ -15,54 +15,54 @@ describe('#ast-utils', () => { }; `; - describe('generateComponentsModule', () => { - const moduleMetadata: NgModuleMetadata = { - imports: ['CommonModule'], - declarations: ['AppComponent'], - providers: ['AppService'] - }; - - const moduleMetadataArgs = Object.keys(moduleMetadata) - .map(key => { - return `${key}: [ ${moduleMetadata[key].join(', ')} ]`; - }) - .join(',\n '); - const ngModuleName = 'MyButtonExamplesModule'; - - const moduleText = ` - @NgModule({ - ${moduleMetadataArgs} - }) - export class ${ngModuleName} {} - `; - - function expectOriginalSource(output: string) { - expect(output).toMatch(`import { AlibComponent } from './basic.component';`); - expect(output).toMatch(`export class MyButtonExamplesModule {}`); - expect(output).toMatch(/declarations: \[ AppComponent \]/); - expect(output).toMatch(/providers: \[ AppService \]/); - expect(output).toMatch(/imports: \[ CommonModule \],/); - } - - it('should generate module success with default export', () => { - const ngSourceFile = createNgSourceFile('module.ts', sourceText); - - const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; - const output = utils.generateComponentsModule(ngSourceFile, moduleText, components); - - expectOriginalSource(output); - expect(output).toMatch(`import { CommonModule } from '@angular/common';`); - expect(output).toMatch(`import { AppComponent } from './app.component';`); - }); - - it('should generate module success with empty', async () => { - const ngSourceFile = createNgSourceFile('module.ts', ''); - const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; - const output = await utils.generateComponentsModule(ngSourceFile, moduleText, components); - - expectOriginalSource(output); - }); - }); + // describe('generateComponentsModule', () => { + // const moduleMetadata: NgModuleMetadata = { + // imports: ['CommonModule'], + // declarations: ['AppComponent'], + // providers: ['AppService'] + // }; + + // const moduleMetadataArgs = Object.keys(moduleMetadata) + // .map(key => { + // return `${key}: [ ${moduleMetadata[key].join(', ')} ]`; + // }) + // .join(',\n '); + // const ngModuleName = 'MyButtonExamplesModule'; + + // const moduleText = ` + // @NgModule({ + // ${moduleMetadataArgs} + // }) + // export class ${ngModuleName} {} + // `; + + // function expectOriginalSource(output: string) { + // expect(output).toMatch(`import { AlibComponent } from './basic.component';`); + // expect(output).toMatch(`export class MyButtonExamplesModule {}`); + // expect(output).toMatch(/declarations: \[ AppComponent \]/); + // expect(output).toMatch(/providers: \[ AppService \]/); + // expect(output).toMatch(/imports: \[ CommonModule \],/); + // } + + // it('should generate module success with default export', () => { + // const ngSourceFile = createNgSourceFile('module.ts', sourceText); + + // const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; + // const output = utils.generateComponentsModule(ngSourceFile, moduleText, components); + + // expectOriginalSource(output); + // expect(output).toMatch(`import { CommonModule } from '@angular/common';`); + // expect(output).toMatch(`import { AppComponent } from './app.component';`); + // }); + + // it('should generate module success with empty', async () => { + // const ngSourceFile = createNgSourceFile('module.ts', ''); + // const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; + // const output = await utils.generateComponentsModule(ngSourceFile, moduleText, components); + + // expectOriginalSource(output); + // }); + // }); describe('insertImports', () => { it('should return changes when sourceFile has import and importStructures is []', () => { @@ -162,9 +162,9 @@ describe('#ast-utils', () => { }); }); - describe('combineNgModuleMetaData', () => { + describe('combineNgModuleMetadata', () => { it('when metadata and append has value', () => { - const combinedMetadata = utils.combineNgModuleMetaData( + const combinedMetadata = utils.combineNgModuleMetadata( { imports: ['FormsModule'], providers: ['AppService'] }, { imports: ['CommonModule'], declarations: ['AppComponent'] } ); @@ -178,7 +178,7 @@ describe('#ast-utils', () => { }); it('when metadata and append has duplicate value', () => { - const combinedMetadata = utils.combineNgModuleMetaData( + const combinedMetadata = utils.combineNgModuleMetadata( { imports: ['CommonModule', 'FormsModule'], providers: ['AppService'] }, { imports: ['CommonModule'], declarations: ['AppComponent'] } ); @@ -192,7 +192,7 @@ describe('#ast-utils', () => { }); it('when append has`t value', () => { - const combinedMetadata = utils.combineNgModuleMetaData({ imports: ['FormsModule'], providers: ['AppService'] }, {}); + const combinedMetadata = utils.combineNgModuleMetadata({ imports: ['FormsModule'], providers: ['AppService'] }, {}); expect(combinedMetadata).toEqual({ declarations: [], entryComponents: [], @@ -203,7 +203,7 @@ describe('#ast-utils', () => { }); it('when metadata has`t value', () => { - const combinedMetadata = utils.combineNgModuleMetaData({}, { imports: ['FormsModule'], providers: ['AppService'] }); + const combinedMetadata = utils.combineNgModuleMetadata({}, { imports: ['FormsModule'], providers: ['AppService'] }); expect(combinedMetadata).toEqual({ declarations: [], entryComponents: [], @@ -214,7 +214,7 @@ describe('#ast-utils', () => { }); it('when metadata and append have not value', () => { - const combinedMetadata = utils.combineNgModuleMetaData({}, {}); + const combinedMetadata = utils.combineNgModuleMetadata({}, {}); expect(combinedMetadata).toEqual({ declarations: [], entryComponents: [], diff --git a/packages/core/src/ast-utils.ts b/packages/core/src/ast-utils.ts index bc648781..e8fbeb3d 100644 --- a/packages/core/src/ast-utils.ts +++ b/packages/core/src/ast-utils.ts @@ -4,22 +4,6 @@ import { toolkit } from '@docgeni/toolkit'; import { applyToUpdateRecorder, Change, InsertChange, RemoveChange } from '@schematics/angular/utility/change'; import { NgModuleMetadata } from './types/module'; -export function generateComponentsModule( - sourceFile: NgSourceFile, - ngModuleText: string, - importStructures: { name: string; moduleSpecifier: string }[] -): string { - const changes = insertImports(sourceFile, importStructures); - const sourceText = sourceFile.origin.getFullText(); - const defaultExportNode = sourceFile.getDefaultExportNode(); - if (defaultExportNode) { - changes.push(new RemoveChange(sourceFile.origin.fileName, defaultExportNode.pos, defaultExportNode.getFullText())); - } - changes.push(new InsertChange(sourceFile.origin.fileName, defaultExportNode ? defaultExportNode.pos : sourceText.length, ngModuleText)); - - return applyChanges(sourceFile.origin.fileName, sourceText, changes); -} - export function insertImports(sourceFile: NgSourceFile, importStructures: { name: string; moduleSpecifier: string }[]): Change[] { const changes: Change[] = []; const allImports = sourceFile.getImportDeclarations(); @@ -64,7 +48,7 @@ export function getNgModuleMetadataFromDefaultExport(sourceFile: NgSourceFile): return ((sourceFile.getDefaultExports() || {}) as unknown) as NgModuleMetadata; } -export function combineNgModuleMetaData(metadata: NgModuleMetadata, appendMetadata: NgModuleMetadata): NgModuleMetadata { +export function combineNgModuleMetadata(metadata: NgModuleMetadata, appendMetadata: NgModuleMetadata): NgModuleMetadata { const defaultModuleMetadata = { declarations: [], entryComponents: [], @@ -75,22 +59,27 @@ export function combineNgModuleMetaData(metadata: NgModuleMetadata, appendMetada metadata = { ...defaultModuleMetadata, ...metadata }; appendMetadata = { ...defaultModuleMetadata, ...appendMetadata }; - return { - declarations: combineArray(metadata.declarations, appendMetadata?.declarations), - entryComponents: combineArray(metadata.entryComponents, appendMetadata?.entryComponents), - providers: combineArray(metadata.providers, appendMetadata.providers), - imports: combineArray(metadata.imports, appendMetadata?.imports), - exports: combineArray(metadata.exports, appendMetadata.exports) + const result: NgModuleMetadata = { + declarations: combineSymbolMetadata(metadata.declarations, appendMetadata?.declarations), + entryComponents: combineSymbolMetadata(metadata.entryComponents, appendMetadata?.entryComponents), + providers: combineSymbolMetadata(metadata.providers, appendMetadata.providers), + imports: combineSymbolMetadata(metadata.imports, appendMetadata?.imports), + exports: combineSymbolMetadata(metadata.exports, appendMetadata.exports) }; + + if (metadata.bootstrap && appendMetadata.bootstrap) { + result.bootstrap = combineSymbolMetadata(metadata.bootstrap, appendMetadata.bootstrap); + } + return result; } -function combineArray(origin: string | string[], append?: string[]) { +function combineSymbolMetadata(origin: string | string[], append?: string | string[]) { const result: string[] = []; if (toolkit.utils.isArray(origin)) { origin.forEach(item => { result.push(item); }); - } else { + } else if (origin) { result.push(`...${origin}`); } if (append) { @@ -114,3 +103,20 @@ export function applyChanges(filePath: string, originContent: string, changes: C const fileEntry = hostTree.get(filePath); return fileEntry.content.toString(); } + +export function generateNgModuleText(ngModuleName: string, moduleMetadata: NgModuleMetadata) { + const moduleMetadataArgs = Object.keys(moduleMetadata) + .map(key => { + return ( + `${key}:` + + (toolkit.utils.isArray(moduleMetadata[key]) ? ` [ ${moduleMetadata[key].join(', ')} ]` : ` ${moduleMetadata[key]}`) + ); + }) + .join(',\n '); + return ` +@NgModule({ + ${moduleMetadataArgs} +}) +export class ${ngModuleName} {} +`; +} diff --git a/packages/core/src/builders/examples-module.spec.ts b/packages/core/src/builders/examples-module.spec.ts index b346f80c..d95961b5 100644 --- a/packages/core/src/builders/examples-module.spec.ts +++ b/packages/core/src/builders/examples-module.spec.ts @@ -15,67 +15,6 @@ export default { }; `; - it('should generate module success with mock', async () => { - const ngSourceFile = createNgSourceFile('module.ts', sourceText); - const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; - const getNgModuleMetadataFromDefaultExportSpy = spyOn(utils, 'getNgModuleMetadataFromDefaultExport'); - const combineNgModuleMetaDataSpy = spyOn(utils, 'combineNgModuleMetaData'); - const generateComponentsModuleSpy = spyOn(utils, 'generateComponentsModule'); - - const metaData = { - declarations: ['AppComponent', 'AlibComponent'], - entryComponents: ['AlibComponent'], - providers: ['AppService'], - imports: ['CommonModule'], - exports: ['AlibComponent'] - }; - combineNgModuleMetaDataSpy.and.returnValue(metaData); - - getNgModuleMetadataFromDefaultExportSpy.and.returnValue({ - declarations: ['AlertComponent'] - }); - - const componentModuleText = `module Text`; - generateComponentsModuleSpy.and.returnValue(componentModuleText); - - const moduleMetadataArgs = Object.keys(metaData) - .map(key => { - return `${key}: [ ${metaData[key].join(', ')} ]`; - }) - .join(',\n '); - const moduleText = ` -@NgModule({ - ${moduleMetadataArgs} -}) -export class MyButtonExamplesModule {} -`; - - const output = await generateComponentExamplesModule(ngSourceFile, 'MyButtonExamplesModule', components); - - expect(getNgModuleMetadataFromDefaultExportSpy).toHaveBeenCalledTimes(1); - expect(getNgModuleMetadataFromDefaultExportSpy).toHaveBeenCalledWith(ngSourceFile); - - expect(combineNgModuleMetaDataSpy).toHaveBeenCalledTimes(1); - expect(combineNgModuleMetaDataSpy).toHaveBeenCalledWith( - { declarations: ['AlertComponent'] }, - { - imports: ['CommonModule'], - declarations: ['AlibComponent'], - entryComponents: ['AlibComponent'], - exports: ['AlibComponent'] - } - ); - - expect(generateComponentsModuleSpy).toHaveBeenCalled(); - expect(generateComponentsModuleSpy).toHaveBeenCalledWith(ngSourceFile, moduleText, [ - ...components, - { name: 'CommonModule', moduleSpecifier: '@angular/common' }, - { name: 'NgModule', moduleSpecifier: '@angular/core' } - ]); - - expect(output).toEqual(componentModuleText); - }); - it('should generate module success ', async () => { const ngSourceFile = createNgSourceFile('module.ts', sourceText); const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; @@ -106,18 +45,18 @@ export class MyButtonExamplesModule {} expect(output).not.toContain(`export default`); }); - const sourceTextWithVarsProviders = ` -import { CommonModule } from '@angular/common'; -import { AppComponent } from './app.component'; - -const myProviders = [ AppService ]; -export default { - imports: [ CommonModule ], - declarations: [ AppComponent ], - providers: myProviders -}; - `; it('should generate module with vars myProviders ', async () => { + const sourceTextWithVarsProviders = ` + import { CommonModule } from '@angular/common'; + import { AppComponent } from './app.component'; + + const myProviders = [ AppService ]; + export default { + imports: [ CommonModule ], + declarations: [ AppComponent ], + providers: myProviders + }; + `; const ngSourceFile = createNgSourceFile('module.ts', sourceTextWithVarsProviders); const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; @@ -125,4 +64,21 @@ export default { expect(output).toContain(`const myProviders = [ AppService ];`); expect(output).toContain(`providers: [ ...myProviders ]`); }); + + it('should generate module with forRoot ', async () => { + const sourceTextWithVarsProviders = ` + import { CommonModule } from '@angular/common'; + import { AppComponent } from './app.component'; + export default { + imports: [ CommonModule ], + declarations: [ AppComponent ], + providers: [ RouterModule.forRoot([1, a, "b"])] + }; + `; + const ngSourceFile = createNgSourceFile('module.ts', sourceTextWithVarsProviders); + const components = [{ name: 'AlibComponent', moduleSpecifier: './basic.component' }]; + + const output = await generateComponentExamplesModule(ngSourceFile, 'MyButtonExamplesModule', components); + expect(output).toContain(`RouterModule.forRoot([1, a, "b"])`); + }); }); diff --git a/packages/core/src/builders/examples-module.ts b/packages/core/src/builders/examples-module.ts index 6ef4778a..c6626245 100644 --- a/packages/core/src/builders/examples-module.ts +++ b/packages/core/src/builders/examples-module.ts @@ -1,6 +1,7 @@ import { NgSourceFile } from '@docgeni/ngdoc'; import { toolkit } from '@docgeni/toolkit'; -import { generateComponentsModule, getNgModuleMetadataFromDefaultExport, combineNgModuleMetaData } from '../ast-utils'; +import { getNgModuleMetadataFromDefaultExport, combineNgModuleMetadata, generateNgModuleText } from '../ast-utils'; +import { NgSourceUpdater } from '../ng-source-updater'; import { NgModuleMetadata } from '../types/module'; export async function generateComponentExamplesModule( @@ -10,35 +11,19 @@ export async function generateComponentExamplesModule( ) { const declarations = components.map(item => item.name); const defaultModuleMetadata = getNgModuleMetadataFromDefaultExport(sourceFile); - const moduleMetadata: NgModuleMetadata = combineNgModuleMetaData(defaultModuleMetadata, { + const moduleMetadata: NgModuleMetadata = combineNgModuleMetadata(defaultModuleMetadata, { imports: ['CommonModule'], declarations: [...declarations], entryComponents: [...declarations], exports: [...declarations] }); - const ngModuleText = generateNgModuleText(ngModuleName, moduleMetadata); - - const module = generateComponentsModule(sourceFile, ngModuleText, [ + const updater = new NgSourceUpdater(sourceFile); + updater.insertImports([ ...components, { name: 'CommonModule', moduleSpecifier: '@angular/common' }, { name: 'NgModule', moduleSpecifier: '@angular/core' } ]); - return module; -} - -function generateNgModuleText(ngModuleName: string, moduleMetadata: NgModuleMetadata) { - const moduleMetadataArgs = Object.keys(moduleMetadata) - .map(key => { - return ( - `${key}:` + - (toolkit.utils.isArray(moduleMetadata[key]) ? ` [ ${moduleMetadata[key].join(', ')} ]` : ` ${moduleMetadata[key]}`) - ); - }) - .join(',\n '); - return ` -@NgModule({ - ${moduleMetadataArgs} -}) -export class ${ngModuleName} {} -`; + updater.insertNgModule(ngModuleName, moduleMetadata); + updater.removeDefaultExport(); + return updater.update(); } diff --git a/packages/core/src/docgeni.ts b/packages/core/src/docgeni.ts index 6f318d97..96881afc 100644 --- a/packages/core/src/docgeni.ts +++ b/packages/core/src/docgeni.ts @@ -114,8 +114,8 @@ export class Docgeni implements DocgeniContext { // clear assets content dist dir await toolkit.fs.remove(this.paths.absSiteAssetsContentPath); // ensure docs content dist dir and assets content dist dir - toolkit.fs.ensureDir(this.paths.absSiteContentPath); - toolkit.fs.ensureDir(this.paths.absSiteAssetsContentPath); + await toolkit.fs.ensureDir(this.paths.absSiteContentPath); + await toolkit.fs.ensureDir(this.paths.absSiteAssetsContentPath); } private initialize() { diff --git a/packages/core/src/ng-source-updater.spec.ts b/packages/core/src/ng-source-updater.spec.ts new file mode 100644 index 00000000..b2ff5c40 --- /dev/null +++ b/packages/core/src/ng-source-updater.spec.ts @@ -0,0 +1,49 @@ +import { createNgSourceFile } from '@docgeni/ngdoc'; +import { NgSourceUpdater } from './ng-source-updater'; + +describe('ng-source-updater', () => { + const sourceText = ` +import { CommonModule } from '@angular/common'; +import { AppComponent } from './app.component'; + +export default { + imports: [ CommonModule ], + declarations: [ AppComponent ], + providers: [ AppService ] +}; + `; + + it('should insert imports', () => { + const ngSourceFile = createNgSourceFile('module.ts', sourceText); + const updater = new NgSourceUpdater(ngSourceFile); + updater.insertImports([ + { name: 'Example', moduleSpecifier: './example' }, + { name: 'Example1', moduleSpecifier: './example1' }, + { name: 'AppRootComponent', moduleSpecifier: './app.component' } + ]); + const result = updater.update(); + expect(result).toContain(`import { Example } from './example';`); + expect(result).toContain(`import { Example1 } from './example1';`); + expect(result).toContain(`import { AppComponent, AppRootComponent } from './app.component';`); + }); + + it('should remove default export', () => { + const ngSourceFile = createNgSourceFile('module.ts', sourceText); + const updater = new NgSourceUpdater(ngSourceFile); + updater.removeDefaultExport(); + const result = updater.update(); + expect(result).not.toContain(`export default {`); + }); + + it('should insert ngModule by text', () => { + const ngSourceFile = createNgSourceFile('module.ts', sourceText); + const updater = new NgSourceUpdater(ngSourceFile); + const moduleText = `\nexport class AppModule {}`; + updater.insertNgModuleByText(moduleText); + const result = updater.update(); + expect(result).toContain(`export class AppModule {}`); + const moduleIndex = result.indexOf(moduleText); + const exportDefaultIndex = result.indexOf(`export default {`); + expect(moduleIndex + moduleText.length).toBeLessThan(exportDefaultIndex); + }); +}); diff --git a/packages/core/src/ng-source-updater.ts b/packages/core/src/ng-source-updater.ts new file mode 100644 index 00000000..19b13cad --- /dev/null +++ b/packages/core/src/ng-source-updater.ts @@ -0,0 +1,45 @@ +import { NgSourceFile } from '@docgeni/ngdoc'; +import { Change, InsertChange, RemoveChange } from '@schematics/angular/utility/change'; +import { applyChanges, generateNgModuleText, insertImports } from './ast-utils'; +import { NgModuleMetadata } from './types/module'; + +export class NgSourceUpdater { + private changes: Change[] = []; + constructor(private sourceFile: NgSourceFile) {} + + insertImports(importStructures: { name: string; moduleSpecifier: string }[]) { + this.changes = [...this.changes, ...insertImports(this.sourceFile, importStructures)]; + return this; + } + + removeDefaultExport() { + const defaultExportNode = this.sourceFile.getDefaultExportNode(); + if (defaultExportNode) { + this.changes.push(new RemoveChange(this.sourceFile.origin.fileName, defaultExportNode.pos, defaultExportNode.getFullText())); + } + return this; + } + + insertNgModuleByText(ngModuleText: string) { + const defaultExportNode = this.sourceFile.getDefaultExportNode(); + this.changes.push( + new InsertChange( + this.sourceFile.origin.fileName, + defaultExportNode ? defaultExportNode.pos : this.sourceFile.length, + ngModuleText + ) + ); + return this; + } + + insertNgModule(moduleName: string, metadata: NgModuleMetadata) { + const moduleText = generateNgModuleText(moduleName, metadata); + this.insertNgModuleByText(moduleText); + return this; + } + + update(): string { + const sourceText = this.sourceFile.origin.getFullText(); + return applyChanges(this.sourceFile.origin.fileName, sourceText, this.changes); + } +} diff --git a/packages/core/src/plugins/built-in-component/built-in-module.spec.ts b/packages/core/src/plugins/built-in-component/built-in-module.spec.ts index bb51adb1..21ac109f 100644 --- a/packages/core/src/plugins/built-in-component/built-in-module.spec.ts +++ b/packages/core/src/plugins/built-in-component/built-in-module.spec.ts @@ -24,31 +24,13 @@ export default { } as ComponentBuilder ]; - const getNgModuleMetadataFromDefaultExportSpy = spyOn(utils, 'getNgModuleMetadataFromDefaultExport'); - const combineNgModuleMetaDataSpy = spyOn(utils, 'combineNgModuleMetaData'); - const generateComponentsModuleSpy = spyOn(utils, 'generateComponentsModule'); - - const metaData = { - declarations: ['AlibComponent', 'AppComponent'], - entryComponents: ['AlibComponent'], - providers: ['AppService'], - imports: ['CommonModule'], - exports: ['AlibComponent'] - }; - combineNgModuleMetaDataSpy.and.returnValue(metaData); - getNgModuleMetadataFromDefaultExportSpy.and.returnValue({ - declarations: ['AlertComponent'] - }); - - const moduleMetadataArgs = Object.keys(metaData) - .map(key => { - return `${key}: [ ${metaData[key].join(', ')} ]`; - }) - .join(',\n '); - const moduleText = ` @NgModule({ -${moduleMetadataArgs} +declarations: [ AppComponent, AlibComponent ], + entryComponents: [ AlibComponent ], + providers: [ AppService ], + imports: [ CommonModule ], + exports: [ AlibComponent ] }) export class CustomComponentsModule { constructor() { @@ -58,29 +40,13 @@ constructor() { } } `; - const output = await generateBuiltInComponentsModule(ngSourceFile, components); - - expect(getNgModuleMetadataFromDefaultExportSpy).toHaveBeenCalledTimes(1); - expect(getNgModuleMetadataFromDefaultExportSpy).toHaveBeenCalledWith(ngSourceFile); - - expect(combineNgModuleMetaDataSpy).toHaveBeenCalledTimes(1); - expect(combineNgModuleMetaDataSpy).toHaveBeenCalledWith( - { declarations: ['AlertComponent'] }, - { - imports: ['CommonModule'], - declarations: ['AlibComponent'], - entryComponents: ['AlibComponent'], - exports: ['AlibComponent'] - } - ); - - expect(generateComponentsModuleSpy).toHaveBeenCalled(); - expect(generateComponentsModuleSpy).toHaveBeenCalledWith(ngSourceFile, moduleText, [ - { name: 'AlibComponent', moduleSpecifier: './alib/alib.component' }, - { name: 'CommonModule', moduleSpecifier: '@angular/common' }, - { name: 'NgModule', moduleSpecifier: '@angular/core' }, - { name: 'addBuiltInComponents', moduleSpecifier: '@docgeni/template' } - ]); + console.log(output); + expect(output).toContain(moduleText); + expect(output).toContain(`import { addBuiltInComponents } from '@docgeni/template';`); + expect(output).toContain(`import { AlibComponent } from './alib/alib.component';`); + expect(output).toContain(`import { NgModule } from '@angular/core';`); + expect(output).toContain(`import { addBuiltInComponents } from '@docgeni/template';`); + expect(output).not.toContain(`export default {`); }); }); diff --git a/packages/core/src/plugins/built-in-component/built-in-module.ts b/packages/core/src/plugins/built-in-component/built-in-module.ts index 37252a7b..cb3f461a 100644 --- a/packages/core/src/plugins/built-in-component/built-in-module.ts +++ b/packages/core/src/plugins/built-in-component/built-in-module.ts @@ -1,5 +1,6 @@ import { NgSourceFile } from '@docgeni/ngdoc'; -import { generateComponentsModule, getNgModuleMetadataFromDefaultExport, combineNgModuleMetaData } from '../../ast-utils'; +import { getNgModuleMetadataFromDefaultExport, combineNgModuleMetadata } from '../../ast-utils'; +import { NgSourceUpdater } from '../../ng-source-updater'; import { NgModuleMetadata } from '../../types/module'; import { ComponentBuilder } from './component-builder'; @@ -8,13 +9,14 @@ export async function generateBuiltInComponentsModule(sourceFile: NgSourceFile, return item.componentData?.name; }); const defaultModuleMetadata = getNgModuleMetadataFromDefaultExport(sourceFile); - const moduleMetadata: NgModuleMetadata = combineNgModuleMetaData(defaultModuleMetadata, { + const moduleMetadata: NgModuleMetadata = combineNgModuleMetadata(defaultModuleMetadata, { imports: ['CommonModule'], declarations: [...declarations], entryComponents: [...declarations], exports: [...declarations] }); + const updater = new NgSourceUpdater(sourceFile); const ngModuleText = await generateNgModuleText(sourceFile, components, moduleMetadata); let componentsData = Array.from(components.values()).map(item => { @@ -26,9 +28,10 @@ export async function generateBuiltInComponentsModule(sourceFile: NgSourceFile, { name: 'NgModule', moduleSpecifier: '@angular/core' }, { name: 'addBuiltInComponents', moduleSpecifier: '@docgeni/template' } ]; - - const module = generateComponentsModule(sourceFile, ngModuleText, componentsData); - return module; + updater.insertImports(componentsData); + updater.insertNgModuleByText(ngModuleText); + updater.removeDefaultExport(); + return updater.update(); } async function generateNgModuleText(sourceFile: NgSourceFile, components: ComponentBuilder[], moduleMetadata: NgModuleMetadata) { diff --git a/packages/core/src/types/module.ts b/packages/core/src/types/module.ts index c27b880d..92a840de 100644 --- a/packages/core/src/types/module.ts +++ b/packages/core/src/types/module.ts @@ -1,7 +1,8 @@ export interface NgModuleMetadata { - declarations?: string[]; - providers?: string[]; - imports?: string[]; - entryComponents?: string[]; - exports?: string[]; + declarations?: string | string[]; + providers?: string | string[]; + imports?: string | string[]; + entryComponents?: string | string[]; + exports?: string | string[]; + bootstrap?: string | string[]; } diff --git a/packages/core/test/fixtures/default-site/output/app/app.module.ts b/packages/core/test/fixtures/default-site/output/app/app.module.ts new file mode 100644 index 00000000..8eeab4fc --- /dev/null +++ b/packages/core/test/fixtures/default-site/output/app/app.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { DocgeniTemplateModule } from '@docgeni/template'; +import { DOCGENI_SITE_PROVIDERS, IMPORT_MODULES, RootComponent } from './content/index'; +@NgModule({ + declarations: [], + imports: [BrowserModule, BrowserAnimationsModule, DocgeniTemplateModule, RouterModule.forRoot([]), ...IMPORT_MODULES], + providers: [...DOCGENI_SITE_PROVIDERS], + bootstrap: [RootComponent] +}) +export class AppModule { + constructor() {} +} diff --git a/packages/core/test/fixtures/default-site/src/app/app.module.ts b/packages/core/test/fixtures/default-site/src/app/app.module.ts new file mode 100644 index 00000000..8eeab4fc --- /dev/null +++ b/packages/core/test/fixtures/default-site/src/app/app.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { DocgeniTemplateModule } from '@docgeni/template'; +import { DOCGENI_SITE_PROVIDERS, IMPORT_MODULES, RootComponent } from './content/index'; +@NgModule({ + declarations: [], + imports: [BrowserModule, BrowserAnimationsModule, DocgeniTemplateModule, RouterModule.forRoot([]), ...IMPORT_MODULES], + providers: [...DOCGENI_SITE_PROVIDERS], + bootstrap: [RootComponent] +}) +export class AppModule { + constructor() {} +} diff --git a/packages/ngdoc/src/ng-source-file.ts b/packages/ngdoc/src/ng-source-file.ts index af595c8a..eb75952a 100644 --- a/packages/ngdoc/src/ng-source-file.ts +++ b/packages/ngdoc/src/ng-source-file.ts @@ -24,6 +24,10 @@ export class NgSourceFile { return this.sourceFile; } + public get length() { + return this.getFullText().length; + } + constructor(sourceFile: ts.SourceFile); constructor(filePath: string, sourceText: string); constructor(filePathOrSourceFile: string | ts.SourceFile, sourceText?: string) { @@ -34,6 +38,10 @@ export class NgSourceFile { } } + public getFullText() { + return this.origin.getFullText(); + } + public getExportedComponents(): NgComponentMetadata[] { const components: NgComponentMetadata[] = []; const classDeclarations = findNodes(this.sourceFile, isExportedClassDeclaration); @@ -81,14 +89,14 @@ export class NgSourceFile { return ngModule; } - public getDefaultExports(): TResult { + public getDefaultExports(): TResult { let exports: TResult; ts.forEachChild(this.sourceFile, node => { if (ts.isExportAssignment(node) && ts.isObjectLiteralExpression(node.expression)) { exports = getObjectLiteralExpressionProperties(node.expression); } }); - return exports; + return (exports as unknown) as TResult; } public getDefaultExportNode(): ts.Node { diff --git a/packages/ngdoc/src/parser/utils.spec.ts b/packages/ngdoc/src/parser/utils.spec.ts index ed0ea1aa..c78c4361 100644 --- a/packages/ngdoc/src/parser/utils.spec.ts +++ b/packages/ngdoc/src/parser/utils.spec.ts @@ -14,5 +14,17 @@ describe('#utils', () => { }; visit(sourceFile); }); + + it('should normalize node text for callExpression', () => { + const sourceFile = ts.createSourceFile('test.ts', `@call({name: book(1, a, "2")}) class A {}`, ts.ScriptTarget.ES2015, true); + const visit = (node: ts.Node) => { + if (ts.isPropertyAssignment(node)) { + expect(node.initializer.getText()).toEqual('book(1, a, "2")'); + expect(getNodeText(node.initializer)).toEqual('book(1, a, "2")'); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + }); }); }); diff --git a/packages/ngdoc/src/parser/utils.ts b/packages/ngdoc/src/parser/utils.ts index fe997c69..f7368076 100644 --- a/packages/ngdoc/src/parser/utils.ts +++ b/packages/ngdoc/src/parser/utils.ts @@ -14,7 +14,13 @@ export function normalizeNodeText(text: string) { } export function getNodeText(node: ts.Node) { - return (node as ts.StringLiteral).text ? (node as ts.StringLiteral).text : normalizeNodeText(node.getText()); + if (ts.isStringLiteral(node)) { + return node.text; + } else if (ts.isCallExpression(node)) { + return node.getText(); + } else { + return normalizeNodeText(node.getText()); + } } export function serializeSymbol(symbol: ts.Symbol, checker: ts.TypeChecker) {