Skip to content

Commit bede232

Browse files
alan-agius4mgechev
authored andcommitted
feat(@schematics/angular): add solutions style tsconfig structure
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
1 parent 93e253b commit bede232

31 files changed

+607
-82
lines changed

Diff for: packages/schematics/angular/application/files/tsconfig.app.json.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
2+
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/app",
55
"types": []

Diff for: packages/schematics/angular/application/files/tsconfig.spec.json.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
2+
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/spec",
55
"types": [

Diff for: packages/schematics/angular/e2e/files/tsconfig.json.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
2+
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/e2e",
55
"module": "commonjs",

Diff for: packages/schematics/angular/e2e/index.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
url,
1919
} from '@angular-devkit/schematics';
2020
import { relativePathToWorkspaceRoot } from '../utility/paths';
21+
import { addTsConfigProjectReferences, verifyBaseTsConfigExists } from '../utility/tsconfig';
2122
import { getWorkspace, updateWorkspace } from '../utility/workspace';
2223
import { Builders } from '../utility/workspace-models';
2324
import { Schema as E2eOptions } from './schema';
@@ -31,6 +32,8 @@ export default function (options: E2eOptions): Rule {
3132
throw new SchematicsException(`Project name "${appProject}" doesn't not exist.`);
3233
}
3334

35+
verifyBaseTsConfigExists(host);
36+
3437
const root = join(normalize(project.root), 'e2e');
3538

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

53+
const e2eTsConfig = `${root}/tsconfig.json`;
5054
const lintTarget = project.targets.get('lint');
5155
if (lintTarget && lintTarget.options && Array.isArray(lintTarget.options.tsConfig)) {
5256
lintTarget.options.tsConfig =
53-
lintTarget.options.tsConfig.concat(`${root}/tsconfig.json`);
57+
lintTarget.options.tsConfig.concat(e2eTsConfig);
5458
}
5559

5660
return chain([
@@ -64,6 +68,9 @@ export default function (options: E2eOptions): Rule {
6468
}),
6569
move(root),
6670
])),
71+
addTsConfigProjectReferences([
72+
e2eTsConfig,
73+
]),
6774
]);
6875
};
6976
}

Diff for: packages/schematics/angular/e2e/index_spec.ts

+14
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8+
import { JsonParseMode, parseJson } from '@angular-devkit/core';
89
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
910
import { Schema as ApplicationOptions } from '../application/schema';
1011
import { Schema as WorkspaceOptions } from '../workspace/schema';
@@ -81,6 +82,19 @@ describe('Application Schematic', () => {
8182
expect(content).toMatch(/🌮-🌯/);
8283
});
8384

85+
it('should add reference in solution style tsconfig', async () => {
86+
const tree = await schematicRunner.runSchematicAsync('e2e', defaultOptions, applicationTree)
87+
.toPromise();
88+
89+
// tslint:disable-next-line:no-any
90+
const { references } = parseJson(tree.readContent('/tsconfig.json').toString(), JsonParseMode.Loose) as any;
91+
expect(references).toEqual([
92+
{ path: './projects/foo/tsconfig.app.json' },
93+
{ path: './projects/foo/tsconfig.spec.json' },
94+
{ path: './projects/foo/e2e/tsconfig.json' },
95+
]);
96+
});
97+
8498
describe('workspace config', () => {
8599
it('should add e2e targets for the app', async () => {
86100
const tree = await schematicRunner.runSchematicAsync('e2e', defaultOptions, applicationTree)

Diff for: packages/schematics/angular/library/files/tsconfig.lib.json.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
2+
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/lib",
55
"target": "es2015",

Diff for: packages/schematics/angular/library/files/tsconfig.spec.json.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json",
2+
"extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/spec",
55
"types": [

Diff for: packages/schematics/angular/library/index.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { NodeDependencyType, addPackageJsonDependency } from '../utility/depende
2525
import { latestVersions } from '../utility/latest-versions';
2626
import { applyLintFix } from '../utility/lint-fix';
2727
import { relativePathToWorkspaceRoot } from '../utility/paths';
28+
import { addTsConfigProjectReferences, verifyBaseTsConfigExists } from '../utility/tsconfig';
2829
import { validateProjectName } from '../utility/validation';
2930
import { getWorkspace, updateWorkspace } from '../utility/workspace';
3031
import { Builders, ProjectType } from '../utility/workspace-models';
@@ -58,9 +59,9 @@ function updateJsonFile<T>(host: Tree, path: string, callback: UpdateJsonFn<T>):
5859
function updateTsConfig(packageName: string, ...paths: string[]) {
5960

6061
return (host: Tree) => {
61-
if (!host.exists('tsconfig.json')) { return host; }
62+
if (!host.exists('tsconfig.base.json')) { return host; }
6263

63-
return updateJsonFile(host, 'tsconfig.json', (tsconfig: TsConfigPartialType) => {
64+
return updateJsonFile(host, 'tsconfig.base.json', (tsconfig: TsConfigPartialType) => {
6465
if (!tsconfig.compilerOptions.paths) {
6566
tsconfig.compilerOptions.paths = {};
6667
}
@@ -73,7 +74,6 @@ function updateTsConfig(packageName: string, ...paths: string[]) {
7374
}
7475

7576
function addDependenciesToPackageJson() {
76-
7777
return (host: Tree) => {
7878
[
7979
{
@@ -174,6 +174,7 @@ export default function (options: LibraryOptions): Rule {
174174
const prefix = options.prefix;
175175

176176
validateProjectName(options.name);
177+
verifyBaseTsConfigExists(host);
177178

178179
// If scoped project (i.e. "@foo/bar"), convert projectDir to "foo/bar".
179180
const projectName = options.name;
@@ -239,6 +240,10 @@ export default function (options: LibraryOptions): Rule {
239240
path: sourceDir,
240241
project: options.name,
241242
}),
243+
addTsConfigProjectReferences([
244+
`${projectRoot}/tsconfig.lib.json`,
245+
`${projectRoot}/tsconfig.spec.json`,
246+
]),
242247
options.lintFix ? applyLintFix(sourceDir) : noop(),
243248
(_tree: Tree, context: SchematicContext) => {
244249
if (!options.skipPackageJson && !options.skipInstall) {

Diff for: packages/schematics/angular/library/index_spec.ts

+22-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
// tslint:disable:no-big-function
9+
import { JsonParseMode, parseJson } from '@angular-devkit/core';
910
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
1011
import { getFileContent } from '../../angular/utility/test';
1112
import { Schema as ComponentOptions } from '../component/schema';
@@ -200,19 +201,19 @@ describe('Library Schematic', () => {
200201
});
201202
});
202203

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

207-
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json');
208+
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.base.json');
208209
expect(tsConfigJson.compilerOptions.paths.foo).toBeTruthy();
209210
expect(tsConfigJson.compilerOptions.paths.foo.length).toEqual(2);
210211
expect(tsConfigJson.compilerOptions.paths.foo[0]).toEqual('dist/foo/foo');
211212
expect(tsConfigJson.compilerOptions.paths.foo[1]).toEqual('dist/foo');
212213
});
213214

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

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

238-
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.json');
239+
const tsConfigJson = getJsonFileContent(tree, 'tsconfig.base.json');
239240
expect(tsConfigJson.compilerOptions.paths).toBeUndefined();
240241
});
241242
});
@@ -264,12 +265,12 @@ describe('Library Schematic', () => {
264265
expect(pkgJson.name).toEqual(scopedName);
265266

266267
const tsConfigJson = JSON.parse(tree.readContent('/projects/myscope/mylib/tsconfig.spec.json'));
267-
expect(tsConfigJson.extends).toEqual('../../../tsconfig.json');
268+
expect(tsConfigJson.extends).toEqual('../../../tsconfig.base.json');
268269

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

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

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

316317
const appTsConfig = JSON.parse(tree.readContent('/foo/tsconfig.lib.json'));
317-
expect(appTsConfig.extends).toEqual('../tsconfig.json');
318+
expect(appTsConfig.extends).toEqual('../tsconfig.base.json');
318319
const specTsConfig = JSON.parse(tree.readContent('/foo/tsconfig.spec.json'));
319-
expect(specTsConfig.extends).toEqual('../tsconfig.json');
320+
expect(specTsConfig.extends).toEqual('../tsconfig.base.json');
320321
});
321322

322323
it(`should add 'production' configuration`, async () => {
@@ -326,4 +327,16 @@ describe('Library Schematic', () => {
326327
const workspace = JSON.parse(tree.readContent('/angular.json'));
327328
expect(workspace.projects.foo.architect.build.configurations.production).toBeDefined();
328329
});
330+
331+
it('should add reference in solution style tsconfig', async () => {
332+
const tree = await schematicRunner.runSchematicAsync('library', defaultOptions, workspaceTree)
333+
.toPromise();
334+
335+
// tslint:disable-next-line:no-any
336+
const { references } = parseJson(tree.readContent('/tsconfig.json').toString(), JsonParseMode.Loose) as any;
337+
expect(references).toEqual([
338+
{ path: './projects/foo/tsconfig.lib.json' },
339+
{ path: './projects/foo/tsconfig.spec.json' },
340+
]);
341+
});
329342
});

Diff for: packages/schematics/angular/library/schema.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"skipTsConfig": {
4242
"type": "boolean",
4343
"default": false,
44-
"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."
44+
"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."
4545
},
4646
"lintFix": {
4747
"type": "boolean",

Diff for: packages/schematics/angular/migrations/migration-collection.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,14 @@
101101
"description": "Update library projects to use tslib version 2 as a direct dependency."
102102
},
103103
"update-workspace-dependencies": {
104-
"version": "10.0.0-beta.7",
104+
"version": "10.0.0-beta.7",
105105
"factory": "./update-10/update-dependencies",
106106
"description": "Workspace dependencies updates."
107+
},
108+
"solution-style-tsconfig": {
109+
"version": "10.0.0-beta.7",
110+
"factory": "./update-10/solution-style-tsconfig",
111+
"description": "Adding \"Solution Style\" tsconfig.json. This improves developer experience using editors powered by TypeScript’s language server."
107112
}
108113
}
109114
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { JsonAstString, JsonParseMode, dirname, join, normalize, parseJsonAst, resolve } from '@angular-devkit/core';
9+
import { DirEntry, Rule, chain } from '@angular-devkit/schematics';
10+
import { findPropertyInAstObject } from '../../utility/json-utils';
11+
import { getWorkspace } from '../../utility/workspace';
12+
13+
const SOLUTIONS_TS_CONFIG_HEADER = '// This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s' +
14+
'language server to improve development experience.\n' +
15+
'// It is not intended to be used to perform a compilation.\n';
16+
17+
function* visitExtendedJsonFiles(directory: DirEntry): IterableIterator<[string, JsonAstString]> {
18+
for (const path of directory.subfiles) {
19+
if (!path.endsWith('.json')) {
20+
continue;
21+
}
22+
23+
const entry = directory.file(path);
24+
if (!entry) {
25+
continue;
26+
}
27+
28+
const jsonAst = parseJsonAst(entry.content.toString(), JsonParseMode.Loose);
29+
if (jsonAst.kind !== 'object') {
30+
continue;
31+
}
32+
33+
const extendsAst = findPropertyInAstObject(jsonAst, 'extends');
34+
// Check if this config has the potential of extended the workspace tsconfig.
35+
// Unlike tslint configuration, tsconfig "extends" cannot be an array.
36+
if (extendsAst?.kind === 'string' && extendsAst.value.endsWith('tsconfig.json')) {
37+
yield [join(directory.path, path), extendsAst];
38+
}
39+
}
40+
41+
for (const path of directory.subdirs) {
42+
if (path === 'node_modules') {
43+
continue;
44+
}
45+
46+
yield* visitExtendedJsonFiles(directory.dir(path));
47+
}
48+
}
49+
50+
function updateTsconfigExtendsRule(): Rule {
51+
return host => {
52+
if (!host.exists('tsconfig.json')) {
53+
return;
54+
}
55+
56+
// Rename workspace tsconfig to base tsconfig.
57+
host.rename('tsconfig.json', 'tsconfig.base.json');
58+
59+
// Iterate over all tsconfig files and change the extends from 'tsconfig.json' 'tsconfig.base.json'
60+
for (const [tsconfigPath, extendsAst] of visitExtendedJsonFiles(host.root)) {
61+
const tsConfigDir = dirname(normalize(tsconfigPath));
62+
if ('/tsconfig.json' !== resolve(tsConfigDir, normalize(extendsAst.value))) {
63+
// tsconfig extends doesn't refer to the workspace tsconfig path.
64+
continue;
65+
}
66+
67+
// Replace last path, json -> base.json
68+
const recorder = host.beginUpdate(tsconfigPath);
69+
const offset = extendsAst.end.offset - 5;
70+
recorder.remove(offset, 4);
71+
recorder.insertLeft(offset, 'base.json');
72+
host.commitUpdate(recorder);
73+
}
74+
};
75+
}
76+
77+
function addSolutionTsConfigRule(): Rule {
78+
return async host => {
79+
const tsConfigPaths = new Set<string>();
80+
const workspace = await getWorkspace(host);
81+
82+
// Find all tsconfig which are refereces used by builders
83+
for (const [, project] of workspace.projects) {
84+
for (const [, target] of project.targets) {
85+
if (!target.options) {
86+
continue;
87+
}
88+
89+
for (const [key, value] of Object.entries(target.options)) {
90+
if ((key === 'tsConfig' || key === 'webWorkerTsConfig') && typeof value === 'string') {
91+
tsConfigPaths.add(value);
92+
}
93+
}
94+
}
95+
}
96+
97+
// Generate the solutions style tsconfig/
98+
const tsConfigContent = {
99+
files: [],
100+
references: [...tsConfigPaths].map(p => ({ path: `./${p}` })),
101+
};
102+
103+
host.create('tsconfig.json', SOLUTIONS_TS_CONFIG_HEADER + JSON.stringify(tsConfigContent, undefined, 2));
104+
};
105+
}
106+
107+
export default function (): Rule {
108+
return (host, context) => {
109+
const logger = context.logger;
110+
111+
if (host.exists('tsconfig.base.json')) {
112+
logger.info('Migration has already been executed.');
113+
114+
return;
115+
}
116+
117+
return chain([
118+
updateTsconfigExtendsRule,
119+
addSolutionTsConfigRule,
120+
]);
121+
};
122+
}

0 commit comments

Comments
 (0)