Skip to content

Commit

Permalink
feat(@schematics/angular): add solutions style tsconfig structure
Browse files Browse the repository at this point in the history
In version 3.9, TypeScript introduced the concept of "Solutions Style" tsconfig to improve developer experience.

More info: https://devblogs.microsoft.com/typescript/announcing-typescript-3-9-rc/#solution-style-tsconfig

Closes #17493 and closes #8138
  • Loading branch information
alan-agius4 authored and mgechev committed May 20, 2020
1 parent 93e253b commit bede232
Show file tree
Hide file tree
Showing 31 changed files with 607 additions and 82 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
"compilerOptions": {
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/app",
"types": []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
"compilerOptions": {
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/spec",
"types": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
"compilerOptions": {
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/e2e",
"module": "commonjs",
Expand Down
9 changes: 8 additions & 1 deletion packages/schematics/angular/e2e/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
url,
} from '@angular-devkit/schematics';
import { relativePathToWorkspaceRoot } from '../utility/paths';
import { addTsConfigProjectReferences, verifyBaseTsConfigExists } from '../utility/tsconfig';
import { getWorkspace, updateWorkspace } from '../utility/workspace';
import { Builders } from '../utility/workspace-models';
import { Schema as E2eOptions } from './schema';
Expand All @@ -31,6 +32,8 @@ export default function (options: E2eOptions): Rule {
throw new SchematicsException(`Project name "${appProject}" doesn't not exist.`);
}

verifyBaseTsConfigExists(host);

const root = join(normalize(project.root), 'e2e');

project.targets.add({
Expand All @@ -47,10 +50,11 @@ export default function (options: E2eOptions): Rule {
},
});

const e2eTsConfig = `${root}/tsconfig.json`;
const lintTarget = project.targets.get('lint');
if (lintTarget && lintTarget.options && Array.isArray(lintTarget.options.tsConfig)) {
lintTarget.options.tsConfig =
lintTarget.options.tsConfig.concat(`${root}/tsconfig.json`);
lintTarget.options.tsConfig.concat(e2eTsConfig);
}

return chain([
Expand All @@ -64,6 +68,9 @@ export default function (options: E2eOptions): Rule {
}),
move(root),
])),
addTsConfigProjectReferences([
e2eTsConfig,
]),
]);
};
}
14 changes: 14 additions & 0 deletions packages/schematics/angular/e2e/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 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 { JsonParseMode, parseJson } from '@angular-devkit/core';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Schema as ApplicationOptions } from '../application/schema';
import { Schema as WorkspaceOptions } from '../workspace/schema';
Expand Down Expand Up @@ -81,6 +82,19 @@ describe('Application Schematic', () => {
expect(content).toMatch(/🌮-🌯/);
});

it('should add reference in solution style tsconfig', async () => {
const tree = await schematicRunner.runSchematicAsync('e2e', defaultOptions, applicationTree)
.toPromise();

// tslint:disable-next-line:no-any
const { references } = parseJson(tree.readContent('/tsconfig.json').toString(), JsonParseMode.Loose) as any;
expect(references).toEqual([
{ path: './projects/foo/tsconfig.app.json' },
{ path: './projects/foo/tsconfig.spec.json' },
{ path: './projects/foo/e2e/tsconfig.json' },
]);
});

describe('workspace config', () => {
it('should add e2e targets for the app', async () => {
const tree = await schematicRunner.runSchematicAsync('e2e', defaultOptions, applicationTree)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
"compilerOptions": {
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/lib",
"target": "es2015",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
"compilerOptions": {
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/spec",
"types": [
Expand Down
11 changes: 8 additions & 3 deletions packages/schematics/angular/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { NodeDependencyType, addPackageJsonDependency } from '../utility/depende
import { latestVersions } from '../utility/latest-versions';
import { applyLintFix } from '../utility/lint-fix';
import { relativePathToWorkspaceRoot } from '../utility/paths';
import { addTsConfigProjectReferences, verifyBaseTsConfigExists } from '../utility/tsconfig';
import { validateProjectName } from '../utility/validation';
import { getWorkspace, updateWorkspace } from '../utility/workspace';
import { Builders, ProjectType } from '../utility/workspace-models';
Expand Down Expand Up @@ -58,9 +59,9 @@ function updateJsonFile<T>(host: Tree, path: string, callback: UpdateJsonFn<T>):
function updateTsConfig(packageName: string, ...paths: string[]) {

return (host: Tree) => {
if (!host.exists('tsconfig.json')) { return host; }
if (!host.exists('tsconfig.base.json')) { return host; }

return updateJsonFile(host, 'tsconfig.json', (tsconfig: TsConfigPartialType) => {
return updateJsonFile(host, 'tsconfig.base.json', (tsconfig: TsConfigPartialType) => {
if (!tsconfig.compilerOptions.paths) {
tsconfig.compilerOptions.paths = {};
}
Expand All @@ -73,7 +74,6 @@ function updateTsConfig(packageName: string, ...paths: string[]) {
}

function addDependenciesToPackageJson() {

return (host: Tree) => {
[
{
Expand Down Expand Up @@ -174,6 +174,7 @@ export default function (options: LibraryOptions): Rule {
const prefix = options.prefix;

validateProjectName(options.name);
verifyBaseTsConfigExists(host);

// If scoped project (i.e. "@foo/bar"), convert projectDir to "foo/bar".
const projectName = options.name;
Expand Down Expand Up @@ -239,6 +240,10 @@ export default function (options: LibraryOptions): Rule {
path: sourceDir,
project: options.name,
}),
addTsConfigProjectReferences([
`${projectRoot}/tsconfig.lib.json`,
`${projectRoot}/tsconfig.spec.json`,
]),
options.lintFix ? applyLintFix(sourceDir) : noop(),
(_tree: Tree, context: SchematicContext) => {
if (!options.skipPackageJson && !options.skipInstall) {
Expand Down
31 changes: 22 additions & 9 deletions packages/schematics/angular/library/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
// tslint:disable:no-big-function
import { JsonParseMode, parseJson } from '@angular-devkit/core';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { getFileContent } from '../../angular/utility/test';
import { Schema as ComponentOptions } from '../component/schema';
Expand Down Expand Up @@ -200,19 +201,19 @@ describe('Library Schematic', () => {
});
});

describe(`update tsconfig.json`, () => {
describe(`update tsconfig.base.json`, () => {
it(`should add paths mapping to empty tsconfig`, async () => {
const tree = await schematicRunner.runSchematicAsync('library', defaultOptions, workspaceTree).toPromise();

const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json');
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.base.json');
expect(tsConfigJson.compilerOptions.paths.foo).toBeTruthy();
expect(tsConfigJson.compilerOptions.paths.foo.length).toEqual(2);
expect(tsConfigJson.compilerOptions.paths.foo[0]).toEqual('dist/foo/foo');
expect(tsConfigJson.compilerOptions.paths.foo[1]).toEqual('dist/foo');
});

it(`should append to existing paths mappings`, async () => {
workspaceTree.overwrite('tsconfig.json', JSON.stringify({
workspaceTree.overwrite('tsconfig.base.json', JSON.stringify({
compilerOptions: {
paths: {
'unrelated': ['./something/else.ts'],
Expand All @@ -222,7 +223,7 @@ describe('Library Schematic', () => {
}));
const tree = await schematicRunner.runSchematicAsync('library', defaultOptions, workspaceTree).toPromise();

const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json');
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.base.json');
expect(tsConfigJson.compilerOptions.paths.foo).toBeTruthy();
expect(tsConfigJson.compilerOptions.paths.foo.length).toEqual(3);
expect(tsConfigJson.compilerOptions.paths.foo[1]).toEqual('dist/foo/foo');
Expand All @@ -235,7 +236,7 @@ describe('Library Schematic', () => {
skipTsConfig: true,
}, workspaceTree).toPromise();

const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json');
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.base.json');
expect(tsConfigJson.compilerOptions.paths).toBeUndefined();
});
});
Expand Down Expand Up @@ -264,12 +265,12 @@ describe('Library Schematic', () => {
expect(pkgJson.name).toEqual(scopedName);

const tsConfigJson = JSON.parse(tree.readContent('/projects/myscope/mylib/tsconfig.spec.json'));
expect(tsConfigJson.extends).toEqual('../../../tsconfig.json');
expect(tsConfigJson.extends).toEqual('../../../tsconfig.base.json');

const cfg = JSON.parse(tree.readContent('/angular.json'));
expect(cfg.projects['@myscope/mylib']).toBeDefined();

const rootTsCfg = JSON.parse(tree.readContent('/tsconfig.json'));
const rootTsCfg = JSON.parse(tree.readContent('/tsconfig.base.json'));
expect(rootTsCfg.compilerOptions.paths['@myscope/mylib']).toEqual(['dist/myscope/mylib/myscope-mylib', 'dist/myscope/mylib']);

const karmaConf = getFileContent(tree, '/projects/myscope/mylib/karma.conf.js');
Expand Down Expand Up @@ -314,9 +315,9 @@ describe('Library Schematic', () => {
expect(buildOpt.tsConfig).toEqual('foo/tsconfig.lib.json');

const appTsConfig = JSON.parse(tree.readContent('/foo/tsconfig.lib.json'));
expect(appTsConfig.extends).toEqual('../tsconfig.json');
expect(appTsConfig.extends).toEqual('../tsconfig.base.json');
const specTsConfig = JSON.parse(tree.readContent('/foo/tsconfig.spec.json'));
expect(specTsConfig.extends).toEqual('../tsconfig.json');
expect(specTsConfig.extends).toEqual('../tsconfig.base.json');
});

it(`should add 'production' configuration`, async () => {
Expand All @@ -326,4 +327,16 @@ describe('Library Schematic', () => {
const workspace = JSON.parse(tree.readContent('/angular.json'));
expect(workspace.projects.foo.architect.build.configurations.production).toBeDefined();
});

it('should add reference in solution style tsconfig', async () => {
const tree = await schematicRunner.runSchematicAsync('library', defaultOptions, workspaceTree)
.toPromise();

// tslint:disable-next-line:no-any
const { references } = parseJson(tree.readContent('/tsconfig.json').toString(), JsonParseMode.Loose) as any;
expect(references).toEqual([
{ path: './projects/foo/tsconfig.lib.json' },
{ path: './projects/foo/tsconfig.spec.json' },
]);
});
});
2 changes: 1 addition & 1 deletion packages/schematics/angular/library/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"skipTsConfig": {
"type": "boolean",
"default": false,
"description": "When true, does not update \"tsconfig.json\" to add a path mapping for the new library. The path mapping is needed to use the library in an app, but can be disabled here to simplify development."
"description": "When true, does not update \"tsconfig.base.json\" to add a path mapping for the new library. The path mapping is needed to use the library in an app, but can be disabled here to simplify development."
},
"lintFix": {
"type": "boolean",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,14 @@
"description": "Update library projects to use tslib version 2 as a direct dependency."
},
"update-workspace-dependencies": {
"version": "10.0.0-beta.7",
"version": "10.0.0-beta.7",
"factory": "./update-10/update-dependencies",
"description": "Workspace dependencies updates."
},
"solution-style-tsconfig": {
"version": "10.0.0-beta.7",
"factory": "./update-10/solution-style-tsconfig",
"description": "Adding \"Solution Style\" tsconfig.json. This improves developer experience using editors powered by TypeScript’s language server."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @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 { JsonAstString, JsonParseMode, dirname, join, normalize, parseJsonAst, resolve } from '@angular-devkit/core';
import { DirEntry, Rule, chain } from '@angular-devkit/schematics';
import { findPropertyInAstObject } from '../../utility/json-utils';
import { getWorkspace } from '../../utility/workspace';

const SOLUTIONS_TS_CONFIG_HEADER = '// This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s' +
'language server to improve development experience.\n' +
'// It is not intended to be used to perform a compilation.\n';

function* visitExtendedJsonFiles(directory: DirEntry): IterableIterator<[string, JsonAstString]> {
for (const path of directory.subfiles) {
if (!path.endsWith('.json')) {
continue;
}

const entry = directory.file(path);
if (!entry) {
continue;
}

const jsonAst = parseJsonAst(entry.content.toString(), JsonParseMode.Loose);
if (jsonAst.kind !== 'object') {
continue;
}

const extendsAst = findPropertyInAstObject(jsonAst, 'extends');
// Check if this config has the potential of extended the workspace tsconfig.
// Unlike tslint configuration, tsconfig "extends" cannot be an array.
if (extendsAst?.kind === 'string' && extendsAst.value.endsWith('tsconfig.json')) {
yield [join(directory.path, path), extendsAst];
}
}

for (const path of directory.subdirs) {
if (path === 'node_modules') {
continue;
}

yield* visitExtendedJsonFiles(directory.dir(path));
}
}

function updateTsconfigExtendsRule(): Rule {
return host => {
if (!host.exists('tsconfig.json')) {
return;
}

// Rename workspace tsconfig to base tsconfig.
host.rename('tsconfig.json', 'tsconfig.base.json');

// Iterate over all tsconfig files and change the extends from 'tsconfig.json' 'tsconfig.base.json'
for (const [tsconfigPath, extendsAst] of visitExtendedJsonFiles(host.root)) {
const tsConfigDir = dirname(normalize(tsconfigPath));
if ('/tsconfig.json' !== resolve(tsConfigDir, normalize(extendsAst.value))) {
// tsconfig extends doesn't refer to the workspace tsconfig path.
continue;
}

// Replace last path, json -> base.json
const recorder = host.beginUpdate(tsconfigPath);
const offset = extendsAst.end.offset - 5;
recorder.remove(offset, 4);
recorder.insertLeft(offset, 'base.json');
host.commitUpdate(recorder);
}
};
}

function addSolutionTsConfigRule(): Rule {
return async host => {
const tsConfigPaths = new Set<string>();
const workspace = await getWorkspace(host);

// Find all tsconfig which are refereces used by builders
for (const [, project] of workspace.projects) {
for (const [, target] of project.targets) {
if (!target.options) {
continue;
}

for (const [key, value] of Object.entries(target.options)) {
if ((key === 'tsConfig' || key === 'webWorkerTsConfig') && typeof value === 'string') {
tsConfigPaths.add(value);
}
}
}
}

// Generate the solutions style tsconfig/
const tsConfigContent = {
files: [],
references: [...tsConfigPaths].map(p => ({ path: `./${p}` })),
};

host.create('tsconfig.json', SOLUTIONS_TS_CONFIG_HEADER + JSON.stringify(tsConfigContent, undefined, 2));
};
}

export default function (): Rule {
return (host, context) => {
const logger = context.logger;

if (host.exists('tsconfig.base.json')) {
logger.info('Migration has already been executed.');

return;
}

return chain([
updateTsconfigExtendsRule,
addSolutionTsConfigRule,
]);
};
}

0 comments on commit bede232

Please sign in to comment.