Skip to content

Commit 289c9bd

Browse files
committed
build: add path mapping support to broccoli typescript (#797)
1 parent ba120c2 commit 289c9bd

File tree

2 files changed

+150
-14
lines changed

2 files changed

+150
-14
lines changed

lib/broccoli/broccoli-typescript.js

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,14 @@ class BroccoliTypeScriptCompiler extends Plugin {
159159

160160
this._tsConfigFiles = tsconfig.files.splice(0);
161161

162-
this._tsOpts = ts.convertCompilerOptionsFromJson(tsconfig.compilerOptions, '', null).options;
162+
this._tsOpts = ts.convertCompilerOptionsFromJson(tsconfig['compilerOptions'],
163+
this.inputPaths[0], this._tsConfigPath).options;
163164
this._tsOpts.rootDir = '';
164165
this._tsOpts.outDir = '';
165166

166167
this._tsServiceHost = new CustomLanguageServiceHost(
167-
this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0]);
168+
this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0],
169+
tsconfig['compilerOptions'].paths, this._tsConfigPath);
168170
this._tsService = ts.createLanguageService(this._tsServiceHost, ts.createDocumentRegistry());
169171
}
170172

@@ -249,13 +251,15 @@ class BroccoliTypeScriptCompiler extends Plugin {
249251
}
250252

251253
class CustomLanguageServiceHost {
252-
constructor(compilerOptions, fileNames, fileRegistry, treeInputPath) {
254+
constructor(compilerOptions, fileNames, fileRegistry, treeInputPath, paths, tsConfigPath) {
253255
this.compilerOptions = compilerOptions;
254256
this.fileNames = fileNames;
255257
this.fileRegistry = fileRegistry;
256258
this.treeInputPath = treeInputPath;
257259
this.currentDirectory = treeInputPath;
258260
this.defaultLibFilePath = ts.getDefaultLibFilePath(compilerOptions).replace(/\\/g, '/');
261+
this.paths = paths;
262+
this.tsConfigPath = tsConfigPath;
259263
this.projectVersion = 0;
260264
}
261265

@@ -272,6 +276,80 @@ class CustomLanguageServiceHost {
272276
return this.projectVersion.toString();
273277
}
274278

279+
/**
280+
* Resolve a moduleName based on the path mapping defined in the tsconfig.
281+
* @param moduleName The module name to resolve.
282+
* @returns {string|boolean} A string that is the path of the module, if found, or a boolean
283+
* indicating if resolution should continue with default.
284+
* @private
285+
*/
286+
_resolveModulePathWithMapping(moduleName) {
287+
// check if module name should be used as-is or it should be mapped to different value
288+
let longestMatchedPrefixLength = 0;
289+
let matchedPattern;
290+
let matchedWildcard;
291+
const paths = this.paths || {};
292+
293+
for (let pattern of Object.keys(paths)) {
294+
if (pattern.indexOf('*') != pattern.lastIndexOf('*')) {
295+
throw `Invalid path mapping pattern: "${pattern}"`;
296+
}
297+
298+
let indexOfWildcard = pattern.indexOf('*');
299+
if (indexOfWildcard !== -1) {
300+
// check if module name starts with prefix, ends with suffix and these two don't overlap
301+
let prefix = pattern.substr(0, indexOfWildcard);
302+
let suffix = pattern.substr(indexOfWildcard + 1);
303+
if (moduleName.length >= prefix.length + suffix.length &&
304+
moduleName.startsWith(prefix) &&
305+
moduleName.endsWith(suffix)) {
306+
307+
// use length of matched prefix as betterness criteria
308+
if (longestMatchedPrefixLength < prefix.length) {
309+
longestMatchedPrefixLength = prefix.length;
310+
matchedPattern = pattern;
311+
matchedWildcard = moduleName.substr(prefix.length, moduleName.length - suffix.length);
312+
}
313+
}
314+
} else {
315+
// Pattern does not contain asterisk - module name should exactly match pattern to succeed.
316+
if (pattern === moduleName) {
317+
matchedPattern = pattern;
318+
matchedWildcard = undefined;
319+
break;
320+
}
321+
}
322+
}
323+
324+
if (!matchedPattern) {
325+
// We fallback to the old module resolution.
326+
return true;
327+
}
328+
329+
// some pattern was matched - module name needs to be substituted
330+
let substitutions = this.paths[matchedPattern];
331+
for (let subst of substitutions) {
332+
if (subst.indexOf('*') != subst.lastIndexOf('*')) {
333+
throw `Invalid substitution: "${subst}" for pattern "${matchedPattern}".`;
334+
}
335+
if (subst == '*') {
336+
// Trigger default module resolution.
337+
return true;
338+
}
339+
// replace * in substitution with matched wildcard
340+
let p = matchedWildcard ? subst.replace('*', matchedWildcard) : subst;
341+
// if substituion is a relative path - combine it with baseUrl
342+
p = path.isAbsolute(p) ? p : path.join(this.treeInputPath, path.dirname(this.tsConfigPath), p);
343+
if (fs.existsSync(p)) {
344+
return p;
345+
}
346+
}
347+
348+
// This is an error; there was a match but no corresponding mapping was valid.
349+
// Do not call the default module resolution.
350+
return false;
351+
}
352+
275353
/**
276354
* This method is called quite a bit to lookup 3 kinds of paths:
277355
* 1/ files in the fileRegistry
@@ -310,6 +388,42 @@ class CustomLanguageServiceHost {
310388
return ts.ScriptSnapshot.fromString(fs.readFileSync(absoluteTsFilePath, FS_OPTS));
311389
}
312390

391+
resolveModuleNames(moduleNames, containingFile)/*: ResolvedModule[]*/ {
392+
return moduleNames.map((moduleName) => {
393+
let shouldResolveUsingDefaultMethod = false;
394+
for (const ext of ['ts', 'd.ts']) {
395+
const name = `${moduleName}.${ext}`;
396+
const maybeModule = this._resolveModulePathWithMapping(name, containingFile);
397+
if (typeof maybeModule == 'string') {
398+
return {
399+
resolvedFileName: maybeModule,
400+
isExternalLibraryImport: false
401+
};
402+
} else {
403+
shouldResolveUsingDefaultMethod = shouldResolveUsingDefaultMethod || maybeModule;
404+
}
405+
}
406+
407+
return shouldResolveUsingDefaultMethod &&
408+
ts.resolveModuleName(moduleName, containingFile, this.compilerOptions, {
409+
fileExists(fileName) {
410+
return fs.existsSync(fileName);
411+
},
412+
readFile(fileName) {
413+
return fs.readFileSync(fileName, 'utf-8');
414+
},
415+
directoryExists(directoryName) {
416+
try {
417+
const stats = fs.statSync(directoryName);
418+
return stats && stats.isDirectory();
419+
} catch (e) {
420+
return false;
421+
}
422+
}
423+
}).resolvedModule;
424+
});
425+
}
426+
313427
getCurrentDirectory() {
314428
return this.currentDirectory;
315429
}

tests/e2e/e2e_workflow.spec.js

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -308,30 +308,52 @@ describe('Basic end-to-end Workflow', function () {
308308
});
309309
});
310310

311-
it('Turn on `noImplicitAny` in tsconfig.json and rebuild', function (done) {
311+
it('Turn on `noImplicitAny` in tsconfig.json and rebuild', function () {
312312
this.timeout(420000);
313313

314314
const configFilePath = path.join(process.cwd(), 'src', 'tsconfig.json');
315315
let config = require(configFilePath);
316316

317317
config.compilerOptions.noImplicitAny = true;
318-
fs.writeFileSync(configFilePath, JSON.stringify(config), 'utf8');
318+
fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
319319

320320
sh.rm('-rf', path.join(process.cwd(), 'dist'));
321321

322322
return ng(['build'])
323-
.then(function () {
323+
.then(() => {
324324
expect(existsSync(path.join(process.cwd(), 'dist'))).to.be.equal(true);
325-
})
325+
});
326+
});
327+
328+
it('Turn on path mapping in tsconfig.json and rebuild', function () {
329+
this.timeout(420000);
330+
331+
const configFilePath = path.join(process.cwd(), 'src', 'tsconfig.json');
332+
let config = require(configFilePath);
333+
334+
config.compilerOptions.baseUrl = '';
335+
336+
// This should fail.
337+
config.compilerOptions.paths = { '@angular/*': [] };
338+
fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
339+
340+
return ng(['build'])
326341
.catch(() => {
327-
throw new Error('Build failed.');
342+
return true;
328343
})
329-
.finally(function () {
330-
// Clean `tmp` folder
331-
process.chdir(path.resolve(root, '..'));
332-
// sh.rm('-rf', './tmp'); // tmp.teardown takes too long
333-
done();
344+
.then((passed) => {
345+
expect(passed).to.equal(true);
346+
})
347+
.then(() => {
348+
// This should succeed.
349+
config.compilerOptions.paths = {
350+
'@angular/*': [ '*' ]
351+
};
352+
fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
353+
})
354+
.then(() => ng(['build']))
355+
.catch(() => {
356+
expect('build failed where it should have succeeded').to.equal('');
334357
});
335358
});
336-
337359
});

0 commit comments

Comments
 (0)