diff --git a/src/cli/run.ts b/src/cli/run.ts index c50ca4683a52..a4b1c58f7657 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -141,6 +141,7 @@ export const runTask = async ( rootDir, sys: configSys, testing: config.testing ?? {}, + transformAliasedImportPaths: config.transformAliasedImportPaths ?? false, }; switch (task) { diff --git a/src/compiler/config/test/validate-config.spec.ts b/src/compiler/config/test/validate-config.spec.ts index 342d48abce83..e56da827d40f 100644 --- a/src/compiler/config/test/validate-config.spec.ts +++ b/src/compiler/config/test/validate-config.spec.ts @@ -83,6 +83,19 @@ describe('validation', () => { }); }); + describe('transformAliasedImportPaths', () => { + it.each([true, false])('set transformAliasedImportPaths %p', (bool) => { + userConfig.transformAliasedImportPaths = bool; + const { config } = validateConfig(userConfig, bootstrapConfig); + expect(config.transformAliasedImportPaths).toBe(bool); + }); + + it('default transformAliasedImportPaths false', () => { + const { config } = validateConfig(userConfig, bootstrapConfig); + expect(config.transformAliasedImportPaths).toBe(false); + }); + }); + describe('enableCache', () => { it('set enableCache true', () => { userConfig.enableCache = true; diff --git a/src/compiler/config/test/validate-service-worker.spec.ts b/src/compiler/config/test/validate-service-worker.spec.ts index b1fed37f9618..d236e97c4531 100644 --- a/src/compiler/config/test/validate-service-worker.spec.ts +++ b/src/compiler/config/test/validate-service-worker.spec.ts @@ -22,6 +22,7 @@ describe('validateServiceWorker', () => { rootDir: '/', sys: mockCompilerSystem(), testing: {}, + transformAliasedImportPaths: false, }; }); diff --git a/src/compiler/config/transpile-options.ts b/src/compiler/config/transpile-options.ts index 2f81f395d725..7b6ed7c1e1d0 100644 --- a/src/compiler/config/transpile-options.ts +++ b/src/compiler/config/transpile-options.ts @@ -123,6 +123,7 @@ export const getTranspileConfig = (input: TranspileOptions) => { const config: Config = { rootDir: compileOpts.currentDirectory, srcDir: compileOpts.currentDirectory, + transformAliasedImportPaths: true, devMode: true, minifyCss: true, minifyJs: false, diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 2b47a03f7ee1..3530978ce83a 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -61,6 +61,7 @@ export const validateConfig = ( rootDir, sys: config.sys ?? bootstrapConfig.sys ?? createSystem({ logger }), testing: config.testing ?? {}, + transformAliasedImportPaths: userConfig.transformAliasedImportPaths ?? false, }; // default devMode false diff --git a/src/compiler/sys/config.ts b/src/compiler/sys/config.ts index 27a19b8c9a80..72061e6e5133 100644 --- a/src/compiler/sys/config.ts +++ b/src/compiler/sys/config.ts @@ -19,6 +19,7 @@ export const getConfig = (userConfig: d.Config): d.ValidatedConfig => { rootDir, sys: userConfig.sys ?? createSystem({ logger }), testing: userConfig ?? {}, + transformAliasedImportPaths: userConfig.transformAliasedImportPaths ?? false, }; setPlatformPath(config.sys.platformPath); diff --git a/src/compiler/transformers/rewrite-aliased-paths.ts b/src/compiler/transformers/rewrite-aliased-paths.ts new file mode 100644 index 000000000000..52fa385a0bc8 --- /dev/null +++ b/src/compiler/transformers/rewrite-aliased-paths.ts @@ -0,0 +1,152 @@ +import { normalizePath } from '@utils'; +import { dirname, relative } from 'path'; +import ts from 'typescript'; + +import { retrieveTsModifiers } from './transform-utils'; + +/** + * Transform modules aliased with `paths` in `tsconfig.json` to relative + * imported in `.d.ts` files. + * + * @returns a TypeScript transformer factory + */ +export function rewriteAliasedDTSImportPaths(): ts.TransformerFactory { + return (transformCtx: ts.TransformationContext) => { + const compilerHost = ts.createCompilerHost(transformCtx.getCompilerOptions()); + + return (tsBundleOrSourceFile) => { + const fileName = ts.isBundle(tsBundleOrSourceFile) + ? tsBundleOrSourceFile.getSourceFile().fileName + : tsBundleOrSourceFile.fileName; + + return ts.visitEachChild(tsBundleOrSourceFile, visit(compilerHost, transformCtx, fileName), transformCtx); + }; + }; +} + +/** + * Transform modules aliased with `paths` in `tsconfig.json` to relative + * imported in source files. + * + * @returns a TypeScript transformer factory + */ +export const rewriteAliasedSourceFileImportPaths = (): ts.TransformerFactory => { + return (transformCtx: ts.TransformationContext) => { + const compilerHost = ts.createCompilerHost(transformCtx.getCompilerOptions()); + + return (tsSourceFile) => { + return ts.visitEachChild(tsSourceFile, visit(compilerHost, transformCtx, tsSourceFile.fileName), transformCtx); + }; + }; +}; + +/** + * This visitor function will modify any {@link ts.ImportDeclaration} nodes to + * rewrite module identifiers which are configured using + * the `paths` parameter in `tsconfig.json` from whatever name they are bound to + * to a relative path from the importer to the importee. + * + * We need to handle this ourselves because while the TypeScript team supports + * using the `paths` configuration to allow location-independent imports across + * a project (i.e. importing a module without having to use its relative + * path from the importing module) the TypeScript compiler has no built-in + * support for resolving these identifiers to the actual modules they point to + * in the `.js` and `.d.ts` files that it emits. + * + * So, for instance, if you have this set in `paths`: + * + * ```json + * "paths": { + * "@utils": ["src/utils/index.ts""], + * } + * ``` + * + * Then you'll be able to import it anywhere in your project: + * + * ```ts + * // src/importing.ts + * import { myUtil } from '@utils'; + * ``` + * + * but unfortunately, in the compiled output you'll still have: + * + * ```js + * // dist/importing.js + * import { myUtil } from "@utils"; + * ``` + * + * instead of what you _most likely_ want, which is: + * + * ```js + * // dist/importing.js + * import { myUtil } from "./utils"; + * ``` + * + * The TypeScript team have stated pretty unequivocally that they will not + * automatically resolve these identifiers to relative paths in output code + * {@see https://github.com/microsoft/TypeScript/issues/10866} and have + * said that resolving these module identifiers is the responsability of module + * bundling and build tools. + * + * So that means we've got to do it! + * + * This visitor function does so by getting the resolved file path to any + * module which is not 1) not external (i.e. not a dependency) and 2) is not + * already a relative, file-path based import. It then replaces the module + * identifier with the relative path from the importer to the importee. + * + * @param compilerHost a TS compiler host + * @param transformCtx a TS transformation context + * @param sourceFilePath the path to the source file being visited + * @returns a visitor which takes a node and optionally transforms imports + */ +const visit = (compilerHost: ts.CompilerHost, transformCtx: ts.TransformationContext, sourceFilePath: string) => { + return (node: ts.Node): ts.VisitResult => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + let importPath = node.moduleSpecifier.text; + + // We will ignore transforming any paths that are already relative paths or + // imports from external modules/packages + if (!importPath.startsWith('.')) { + const module = ts.resolveModuleName( + importPath, + sourceFilePath, + transformCtx.getCompilerOptions(), + compilerHost + ); + + const hasResolvedFileName = module.resolvedModule?.resolvedFileName != null; + const isModuleFromNodeModules = module.resolvedModule?.isExternalLibraryImport === true; + const shouldTranspileImportPath = hasResolvedFileName && !isModuleFromNodeModules; + + if (shouldTranspileImportPath) { + // Create a regular expression that will be used to remove the last file extension + // from the import path + const extensionRegex = new RegExp( + Object.values(ts.Extension) + .map((extension) => `${extension}$`) + .join('|') + ); + + // In order to make sure the relative path works when the destination depth is different than the source + // file structure depth, we need to determine where the resolved file exists relative to the destination directory + const resolvePathInDestination = module.resolvedModule.resolvedFileName; + + importPath = normalizePath( + relative(dirname(sourceFilePath), resolvePathInDestination).replace(extensionRegex, '') + ); + } + } + + return transformCtx.factory.updateImportDeclaration( + node, + retrieveTsModifiers(node), + node.importClause, + transformCtx.factory.createStringLiteral(importPath), + node.assertClause + ); + } + + return ts.visitEachChild(node, visit(compilerHost, transformCtx, sourceFilePath), transformCtx); + }; +}; diff --git a/src/compiler/transpile/run-program.ts b/src/compiler/transpile/run-program.ts index d3c0d48419fc..5706ec4dc309 100644 --- a/src/compiler/transpile/run-program.ts +++ b/src/compiler/transpile/run-program.ts @@ -1,12 +1,13 @@ import { loadTypeScriptDiagnostics, normalizePath } from '@utils'; import { basename, join, relative } from 'path'; -import type ts from 'typescript'; +import ts from 'typescript'; import type * as d from '../../declarations'; import { updateComponentBuildConditionals } from '../app-core/app-data'; import { resolveComponentDependencies } from '../entries/resolve-component-dependencies'; import { getComponentsFromModules, isOutputTargetDistTypes } from '../output-targets/output-utils'; import { convertDecoratorsToStatic } from '../transformers/decorators-to-static/convert-decorators'; +import { rewriteAliasedDTSImportPaths, rewriteAliasedSourceFileImportPaths } from '../transformers/rewrite-aliased-paths'; import { updateModule } from '../transformers/static-to-meta/parse-static'; import { generateAppTypes } from '../types/generate-app-types'; import { updateStencilTypesImports } from '../types/stencil-types'; @@ -50,10 +51,30 @@ export const runTsProgram = async ( } }; - // Emit files that changed - tsBuilder.emit(undefined, emitCallback, undefined, false, { + const transformers: ts.CustomTransformers = { before: [convertDecoratorsToStatic(config, buildCtx.diagnostics, tsTypeChecker)], - }); + afterDeclarations: [], + }; + + if (config.transformAliasedImportPaths) { + transformers.before.push(rewriteAliasedSourceFileImportPaths()); + // TypeScript handles the generation of JS and `.d.ts` files through + // different pipelines. One (possibly surprising) consequence of this is + // that if you modify a source file using a transforming it will not + // automatically result in changes to the corresponding `.d.ts` file. + // Instead, if you want to, for instance, rewrite some import specifiers in + // both the source file _and_ its typedef you'll need to run a transformer + // for both of them. + // + // See here: https://github.com/itsdouges/typescript-transformer-handbook#transforms + // and here: https://github.com/microsoft/TypeScript/pull/23946 + // + // This quirk is not terribly well documented unfortunately. + transformers.afterDeclarations.push(rewriteAliasedDTSImportPaths()); + } + + // Emit files that changed + tsBuilder.emit(undefined, emitCallback, undefined, false, transformers); const changedmodules = Array.from(compilerCtx.changedModules.keys()); buildCtx.debug('Transpiled modules: ' + JSON.stringify(changedmodules, null, '\n')); diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index cc5243bad995..484b2c479a92 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -105,6 +105,14 @@ export interface StencilConfig { */ srcDir?: string; + /** + * Sets whether or not Stencil should transform path aliases set in a project's + * `tsconfig.json` from the assigned module aliases to resolved relative paths. + * + * This behavior is opt-in and hence this flag defaults to `false`. + */ + transformAliasedImportPaths?: boolean; + /** * Passes custom configuration down to the "@rollup/plugin-commonjs" that Stencil uses under the hood. * For further information: https://stenciljs.com/docs/module-bundling @@ -435,7 +443,8 @@ type StrictConfigFields = | 'packageJsonFilePath' | 'rootDir' | 'sys' - | 'testing'; + | 'testing' + | 'transformAliasedImportPaths'; /** * A version of {@link Config} that makes certain fields required. This type represents a valid configuration entity. diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index 6dd5187e9a49..7ad0e7c986ed 100644 --- a/src/testing/mocks.ts +++ b/src/testing/mocks.ts @@ -42,6 +42,7 @@ export function mockValidatedConfig(overrides: Partial = {}): V rootDir, sys: createTestingSystem(), testing: {}, + transformAliasedImportPaths: false, ...overrides, }; }