Skip to content
This repository was archived by the owner on Apr 4, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/schematics/angular/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
"factory": "./service",
"description": "Create an Angular service.",
"schema": "./service/schema.json"
},
"universal": {
"factory": "./universal",
"description": "Create an Angular universal app.",
"schema": "./universal/schema.json"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
imports: [
AppModule,
ServerModule,
],
bootstrap: [AppComponent],
})
export class <%= rootModuleClassName %> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { <%= rootModuleClassName %> } from './<%= appDir %>/<%= stripTsExtension(rootModuleFileName) %>';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../<%= outDir %>",
"baseUrl": "./",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "<%= appDir %>/<%= stripTsExtension(rootModuleFileName) %>#<%= rootModuleClassName %>"
}
}
203 changes: 203 additions & 0 deletions packages/schematics/angular/universal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { normalize } from '@angular-devkit/core';
import {
Rule,
SchematicContext,
SchematicsException,
Tree,
apply,
chain,
mergeWith,
template,
url,
} from '@angular-devkit/schematics';
import 'rxjs/add/operator/merge';
import * as ts from 'typescript';
import * as stringUtils from '../strings';
import { findNode, getDecoratorMetadata, getSourceNodes } from '../utility/ast-utils';
import { InsertChange } from '../utility/change';
import { AppConfig, getAppFromConfig, getConfig } from '../utility/config';
import { findBootstrapModuleCall } from '../utility/ng-ast-utils';
import { Schema as UniversalOptions } from './schema';


function updateConfigFile(options: UniversalOptions): Rule {
return (host: Tree) => {
const config = getConfig(host);
const clientApp = getAppFromConfig(config, options.clientApp);
if (clientApp === null) {
throw new SchematicsException('Client app not found.');
}
options.test = options.test || clientApp.test;
const serverApp: AppConfig = {
...clientApp,
platform: 'server',
root: options.root,
outDir: options.outDir,
index: options.index,
main: options.main,
test: options.test,
tsconfig: options.tsconfigFileName,
testTsconfig: options.testTsconfigFileName,
};
if (!config.apps) {
config.apps = [];
}
config.apps.push(serverApp);

host.overwrite('/.angular-cli.json', JSON.stringify(config, null, 2));

return host;
};
}

function findBootstrapModulePath(host: Tree, mainPath: string): string {
const bootstrapCall = findBootstrapModuleCall(host, mainPath);
if (!bootstrapCall) {
throw new SchematicsException('Bootstrap call not found');
}

const bootstrapModule = bootstrapCall.arguments[0];

const mainBuffer = host.read(mainPath);
if (!mainBuffer) {
throw new SchematicsException(`Client app main file (${mainPath}) not found`);
}
const mainText = mainBuffer.toString('utf-8');
const source = ts.createSourceFile(mainPath, mainText, ts.ScriptTarget.Latest, true);
const allNodes = getSourceNodes(source);
const bootstrapModuleRelativePath = allNodes
.filter(node => node.kind === ts.SyntaxKind.ImportDeclaration)
.filter(imp => {
return findNode(imp, ts.SyntaxKind.Identifier, bootstrapModule.getText());
})
.map((imp: ts.ImportDeclaration) => {
const modulePathStringLiteral = <ts.StringLiteral> imp.moduleSpecifier;

return modulePathStringLiteral.text;
})[0];

return bootstrapModuleRelativePath;
}

function findBrowserModuleImport(host: Tree, modulePath: string): ts.Node {
const moduleBuffer = host.read(modulePath);
if (!moduleBuffer) {
throw new SchematicsException(`Module file (${modulePath}) not found`);
}
const moduleFileText = moduleBuffer.toString('utf-8');

const source = ts.createSourceFile(modulePath, moduleFileText, ts.ScriptTarget.Latest, true);

const decoratorMetadata = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0];
const browserModuleNode = findNode(decoratorMetadata, ts.SyntaxKind.Identifier, 'BrowserModule');

if (browserModuleNode === null) {
throw new SchematicsException(`Cannot find BrowserModule import in ${modulePath}`);
}

return browserModuleNode;
}

function wrapBootstrapCall(options: UniversalOptions): Rule {
return (host: Tree) => {
const config = getConfig(host);
const clientApp = getAppFromConfig(config, options.clientApp);
if (clientApp === null) {
throw new SchematicsException('Client app not found.');
}
const mainPath = normalize(`/${clientApp.root}/${clientApp.main}`);
let bootstrapCall: ts.Node | null = findBootstrapModuleCall(host, mainPath);
if (bootstrapCall === null) {
throw new SchematicsException('Bootstrap module not found.');
}

let bootstrapCallExpression: ts.Node | null = null;
let currentCall = bootstrapCall;
while (bootstrapCallExpression === null && currentCall.parent) {
currentCall = currentCall.parent;
if (currentCall.kind === ts.SyntaxKind.ExpressionStatement) {
bootstrapCallExpression = currentCall;
}
}
bootstrapCall = currentCall;

const recorder = host.beginUpdate(mainPath);
const beforeText = `document.addEventListener('DOMContentLoaded', () => {\n `;
const afterText = `\n});`;
recorder.insertLeft(bootstrapCall.getStart(), beforeText);
recorder.insertRight(bootstrapCall.getEnd(), afterText);
host.commitUpdate(recorder);
};
}

function addServerTransition(options: UniversalOptions): Rule {
return (host: Tree) => {
const config = getConfig(host);
const clientApp = getAppFromConfig(config, options.clientApp);
if (clientApp === null) {
throw new SchematicsException('Client app not found.');
}
const mainPath = normalize(`/${clientApp.root}/${clientApp.main}`);

const bootstrapModuleRelativePath = findBootstrapModulePath(host, mainPath);
const bootstrapModulePath = normalize(`/${clientApp.root}/${bootstrapModuleRelativePath}.ts`);

const browserModuleImport = findBrowserModuleImport(host, bootstrapModulePath);
const appId = options.appId;
const transitionCall = `.withServerTransition({ appId: '${appId}' })`;
const position = browserModuleImport.pos + browserModuleImport.getFullText().length;
const transitionCallChange = new InsertChange(
bootstrapModulePath, position, transitionCall);

const transitionCallRecorder = host.beginUpdate(bootstrapModulePath);
transitionCallRecorder.insertLeft(transitionCallChange.pos, transitionCallChange.toAdd);
host.commitUpdate(transitionCallRecorder);
};
}

function addDependencies(): Rule {
return (host: Tree) => {
const pkgPath = '/package.json';
const buffer = host.read(pkgPath);
if (buffer === null) {
throw new SchematicsException('Could not find package.json');
}

const pkg = JSON.parse(buffer.toString());

const ngCoreVersion = Object.keys(pkg.dependencies)
.filter((key: string) => key === '@angular/core')[0];
pkg.dependencies['@angular/platform-server'] = ngCoreVersion;

host.overwrite(pkgPath, JSON.stringify(pkg, null, 2));

return host;
};
}

export default function (options: UniversalOptions): Rule {
return (host: Tree, context: SchematicContext) => {
const templateSource = apply(url('./files'), [
template({
...stringUtils,
...options as object,
stripTsExtension: (s: string) => { return s.replace(/\.ts$/, ''); },
}),
]);

return chain([
mergeWith(templateSource),
addDependencies(),
updateConfigFile(options),
wrapBootstrapCall(options),
addServerTransition(options),
])(host, context);
};
}
110 changes: 110 additions & 0 deletions packages/schematics/angular/universal/index_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { Schema as ApplicationOptions } from '../application/schema';
import { Schema as UniversalOptions } from './schema';


describe('Universal Schematic', () => {
const schematicRunner = new SchematicTestRunner(
'@schematics/angular',
path.join(__dirname, '../collection.json'),
);
const defaultOptions: UniversalOptions = {
name: 'foo',
};

let appTree: Tree;

beforeEach(() => {
const appOptions: ApplicationOptions = {
directory: '',
name: 'app',
path: 'src',
prefix: '',
sourceDir: 'src',
inlineStyle: false,
inlineTemplate: false,
viewEncapsulation: 'None',
changeDetection: 'Default',
version: '1.2.3',
routing: false,
style: 'css',
skipTests: false,
minimal: false,
};
appTree = schematicRunner.runSchematic('application', appOptions);
});

it('should create a root module file', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/src/app/app.server.module.ts';
const file = tree.files.filter(f => f === filePath)[0];
expect(file).toBeDefined();
});

it('should create a main file', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/src/main.server.ts';
const file = tree.files.filter(f => f === filePath)[0];
expect(file).toBeDefined();
const contents = tree.read(filePath);
expect(contents).toMatch(/export { AppServerModule } from '\.\/app\/app\.server\.module'/);
});

it('should create a tsconfig file', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/src/tsconfig.server.json';
const file = tree.files.filter(f => f === filePath)[0];
expect(file).toBeDefined();
const contents = tree.read(filePath);
expect(contents).toMatch(/\"outDir\": \"\.\.\/dist-server\/\"/);
});

it('should add dependency: @angular/platform-server', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/package.json';
const contents = tree.read(filePath);
expect(contents).toMatch(/\"@angular\/platform-server\": \"/);
});

it('should update .angular-cli.json with a server app', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/.angular-cli.json';
const contents = tree.read(filePath) || new Buffer('');

const config = JSON.parse(contents.toString());
expect(config.apps.length).toEqual(2);
const app = config.apps[1];
expect(app.platform).toEqual('server');
expect(app.root).toEqual('src');
expect(app.outDir).toEqual('dist-server/');
expect(app.index).toEqual('index.html');
expect(app.main).toEqual('main.server.ts');
expect(app.test).toEqual('test.ts');
expect(app.tsconfig).toEqual('tsconfig.server');
expect(app.testTsconfig).toEqual('tsconfig.spec');
expect(app.environmentSource).toEqual('environments/environment.ts');
});

it('should add a server transition to BrowerModule import', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/src/app/app.module.ts';
const contents = tree.read(filePath);
expect(contents).toMatch(/BrowserModule\.withServerTransition\({ appId: 'serverApp' }\)/);
});

it('should wrap the bootstrap call in a DOMContentLoaded event handler', () => {
const tree = schematicRunner.runSchematic('universal', defaultOptions, appTree);
const filePath = '/src/main.ts';
const contents = tree.read(filePath);
expect(contents).toMatch(/document.addEventListener\('DOMContentLoaded', \(\) => {/);
});
});
Loading