diff --git a/src/cli/run.ts b/src/cli/run.ts index ad39aac873b..baf70cb805a 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -127,6 +127,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 342d48abce8..e56da827d40 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 b1fed37f961..d236e97c453 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 2f81f395d72..a77b83c5ddf 100644 --- a/src/compiler/config/transpile-options.ts +++ b/src/compiler/config/transpile-options.ts @@ -40,7 +40,23 @@ export const getTranspileResults = (code: string, input: TranspileOptions) => { const transpileCtx = { sys: null as CompilerSystem }; -export const getTranspileConfig = (input: TranspileOptions) => { +/** + * Configuration necessary for transpilation + */ +interface TranspileConfig { + compileOpts: TranspileOptions; + config: Config; + transformOpts: TransformOptions; +} + +/** + * Get configuration necessary to carry out transpilation, including a Stencil + * configuration, transformation options, and transpilation options. + * + * @param input options for Stencil's transpiler (string-to-string compiler) + * @returns the options and configuration necessary for transpilation + */ +export const getTranspileConfig = (input: TranspileOptions): TranspileConfig => { if (input.sys) { transpileCtx.sys = input.sys; } else if (!transpileCtx.sys) { @@ -121,16 +137,17 @@ export const getTranspileConfig = (input: TranspileOptions) => { }; const config: Config = { - rootDir: compileOpts.currentDirectory, - srcDir: compileOpts.currentDirectory, + _isTesting: true, devMode: true, + enableCache: false, minifyCss: true, minifyJs: false, - _isTesting: true, - validateTypes: false, - enableCache: false, + rootDir: compileOpts.currentDirectory, + srcDir: compileOpts.currentDirectory, sys: transpileCtx.sys, + transformAliasedImportPaths: input.transformAliasedImportPaths, tsCompilerOptions, + validateTypes: false, }; return { diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 2b47a03f7ee..3530978ce83 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 27a19b8c9a8..72061e6e513 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/sys/typescript/typescript-resolve-module.ts b/src/compiler/sys/typescript/typescript-resolve-module.ts index 594b7bbd769..6e495a36222 100644 --- a/src/compiler/sys/typescript/typescript-resolve-module.ts +++ b/src/compiler/sys/typescript/typescript-resolve-module.ts @@ -19,6 +19,7 @@ import { } from '../resolve/resolve-utils'; import { patchTsSystemFileSystem } from './typescript-sys'; +// TODO(STENCIL-728): fix typing of `inMemoryFs` parameter in `patchTypescript`, related functions export const patchTypeScriptResolveModule = (config: d.Config, inMemoryFs: InMemoryFileSystem) => { let compilerExe: string; if (config.sys) { @@ -100,7 +101,25 @@ export const patchedTsResolveModule = ( let resolvedFileName = join(containingDir, moduleName); resolvedFileName = normalizePath(ensureExtension(resolvedFileName, containingFile)); - if (isAbsolute(resolvedFileName) && !inMemoryFs.accessSync(resolvedFileName)) { + // In some cases `inMemoryFs` will not be defined here, so we should use + // `accessSync` on `config.sys` instead. This is because this function is + // called by `patchTypeScriptResolveModule` which is then in turn called by + // `patchTypescript`. If you check out that function it takes an + // `InMemoryFileSystem` as its second parameter: + // + // https://github.com/ionic-team/stencil/blob/5b4bb06a4d0369c09aeb63b1a626ff8df9464117/src/compiler/sys/typescript/typescript-sys.ts#L165-L175 + // + // but if you look at its call sites there are a few where we pass `null` + // instead, eg: + // + // https://github.com/ionic-team/stencil/blob/5b4bb06a4d0369c09aeb63b1a626ff8df9464117/src/compiler/transpile.ts#L42-L44 + // + // so in short the type for `inMemoryFs` here is not accurate, so we need + // to add a runtime check here to avoid an error. + // + // TODO(STENCIL-728): fix typing of `inMemoryFs` parameter in `patchTypescript`, related functions + const accessSync = inMemoryFs?.accessSync ?? config.sys.accessSync; + if (isAbsolute(resolvedFileName) && !accessSync(resolvedFileName)) { return null; } diff --git a/src/compiler/sys/typescript/typescript-sys.ts b/src/compiler/sys/typescript/typescript-sys.ts index 0b1b4e5c09d..9a9721b07aa 100644 --- a/src/compiler/sys/typescript/typescript-sys.ts +++ b/src/compiler/sys/typescript/typescript-sys.ts @@ -8,6 +8,7 @@ import { fetchUrlSync } from '../fetch/fetch-module-sync'; import { InMemoryFileSystem } from '../in-memory-fs'; import { patchTypeScriptResolveModule } from './typescript-resolve-module'; +// TODO(STENCIL-728): fix typing of `inMemoryFs` parameter in `patchTypescript`, related functions export const patchTsSystemFileSystem = ( config: d.Config, compilerSys: d.CompilerSystem, @@ -162,6 +163,7 @@ const patchTsSystemWatch = (compilerSystem: d.CompilerSystem, tsSys: ts.System) }; }; +// TODO(STENCIL-728): fix typing of `inMemoryFs` parameter in `patchTypescript`, related functions export const patchTypescript = (config: d.Config, inMemoryFs: InMemoryFileSystem) => { if (!(ts as any).__patched) { if (config.sys) { diff --git a/src/compiler/transformers/rewrite-aliased-paths.ts b/src/compiler/transformers/rewrite-aliased-paths.ts new file mode 100644 index 00000000000..ac75e953dcf --- /dev/null +++ b/src/compiler/transformers/rewrite-aliased-paths.ts @@ -0,0 +1,173 @@ +import { normalizePath } from '@utils'; +import { dirname, relative } from 'path'; +import ts from 'typescript'; + +import { retrieveTsModifiers } from './transform-utils'; + +/** + * Transform module import paths aliased with `paths` in `tsconfig.json` to + * relative imported in `.d.ts` files. + * + * @param transformCtx a TypeScript transformation context + * @returns a TypeScript transformer + */ +export function rewriteAliasedDTSImportPaths( + transformCtx: ts.TransformationContext +): ts.Transformer { + 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. + * + * @param transformCtx a TypeScript transformation context + * @returns a TypeScript transformer + */ +export function rewriteAliasedSourceFileImportPaths( + transformCtx: ts.TransformationContext +): ts.Transformer { + const compilerHost = ts.createCompilerHost(transformCtx.getCompilerOptions()); + + return (tsSourceFile) => { + return ts.visitEachChild(tsSourceFile, visit(compilerHost, transformCtx, tsSourceFile.fileName), transformCtx); + }; +} + +/** + * Visitor function used when rewriting aliased paths in both source files and + * `.d.ts` output. + * + * @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 + */ +function visit(compilerHost: ts.CompilerHost, transformCtx: ts.TransformationContext, sourceFilePath: string) { + return (node: ts.Node): ts.VisitResult => { + if (!ts.isImportDeclaration(node)) { + return node; + } + return rewriteAliasedImport(compilerHost, transformCtx, sourceFilePath, node); + }; +} + +/** + * This will rewrite the module identifier for a {@link ts.ImportDeclaration} + * node to turn identifiers which are configured using the `paths` parameter in + * `tsconfig.json` from whatever name they are bound 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 responsibility of module + * bundling and build tools. + * + * So that means we've got to do it! + * + * This 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 + * @param node a TypeScript import declaration node + * @returns a visitor which takes a node and optionally transforms imports + */ +function rewriteAliasedImport( + compilerHost: ts.CompilerHost, + transformCtx: ts.TransformationContext, + sourceFilePath: string, + node: ts.ImportDeclaration +): ts.ImportDeclaration { + // this most likely won't be the case, but we'll leave it to TypeScript to + // error in the case that the user does something like `import foo from 3;` + if (!ts.isStringLiteral(node.moduleSpecifier)) { + return node; + } + + 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('.')) { + return node; + } + + 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) { + return node; + } + + // 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('|') + ); + + const resolvePathInDestination = module.resolvedModule.resolvedFileName; + // get the normalized relative path from the importer to the importee + importPath = normalizePath(relative(dirname(sourceFilePath), resolvePathInDestination).replace(extensionRegex, '')); + + return transformCtx.factory.updateImportDeclaration( + node, + retrieveTsModifiers(node), + node.importClause, + transformCtx.factory.createStringLiteral(importPath), + node.assertClause + ); +} diff --git a/src/compiler/transformers/test/rewrite-aliased-paths.spec.ts b/src/compiler/transformers/test/rewrite-aliased-paths.spec.ts new file mode 100644 index 00000000000..8d836c39c8f --- /dev/null +++ b/src/compiler/transformers/test/rewrite-aliased-paths.spec.ts @@ -0,0 +1,147 @@ +import { CompilerCtx } from '@stencil/core/declarations'; +import { mockCompilerCtx, mockValidatedConfig } from '@stencil/core/testing'; +import { normalizePath } from '@utils'; +import path from 'path'; +import ts from 'typescript'; + +import { patchTypescript } from '../../sys/typescript/typescript-sys'; +import { rewriteAliasedDTSImportPaths, rewriteAliasedSourceFileImportPaths } from '../rewrite-aliased-paths'; +import { transpileModule } from './transpile'; + +/** + * Helper function for running the transpilation for tests in this module. + * This sets up a config, patches typescript, declares a mock TypeScript + * configuration, writes some files to the in-memory FS, and then finally + * transpiles the provided code. + * + * @param component the string of a component + * @returns the tranpiled module + */ +async function pathTransformTranspile(component: string) { + const compilerContext: CompilerCtx = mockCompilerCtx(); + const config = mockValidatedConfig(); + + patchTypescript(config, compilerContext.fs); + + const mockPathsConfig: ts.CompilerOptions = { + paths: { + '@namespace': [path.join(config.rootDir, 'name/space.ts')], + '@namespace/subdir': [path.join(config.rootDir, 'name/space/subdir.ts')], + }, + declaration: true, + }; + + // we need to have files in the `inMemoryFs` which TypeScript + // can resolve, otherwise it won't find the module and won't + // transform the module ID + await compilerContext.fs.writeFile(path.join(config.rootDir, 'name/space.ts'), 'export const foo = x => x'); + await compilerContext.fs.writeFile(path.join(config.rootDir, 'name/space/subdir.ts'), 'export const bar = x => x;'); + + const inputFileName = normalizePath(path.join(config.rootDir, 'module.tsx')); + + return transpileModule( + component, + null, + compilerContext, + [rewriteAliasedSourceFileImportPaths], + [], + [rewriteAliasedDTSImportPaths], + mockPathsConfig, + inputFileName + ); +} + +describe('rewrite alias module paths transform', () => { + it('should rewrite an aliased module identifier', async () => { + const t = await pathTransformTranspile(` + import { foo } from "@namespace"; + export class CmpA { + render() { + return { foo("bar") } + } + } + `); + + expect(t.outputText).toBe( + 'import { foo } from "./name/space";export class CmpA { render() { return h("some-cmp", null, foo("bar")); }}' + ); + }); + + it('should rewrite a nested aliased modules identifier', async () => { + const t = await pathTransformTranspile(` + import { foo } from "@namespace/subdir"; + export class CmpA { + render() { + return { foo("bar") } + } + } + `); + + expect(t.outputText).toBe( + 'import { foo } from "./name/space/subdir";export class CmpA { render() { return h("some-cmp", null, foo("bar")); }}' + ); + }); + + it('should rewrite an aliased modules identifier in a .d.ts', async () => { + const t = await pathTransformTranspile(` + import { Foo } from "@namespace"; + + export class CmpA { + @Prop() + field: Foo = { bar: "yes" }; + + render() { + return + } + } + `); + + expect(t.declarationOutputText).toBe( + 'import { Foo } from "./name/space";export declare class CmpA { field: Foo; render(): any;}' + ); + }); + + it('should rewrite a nested aliased modules identifier in a .d.ts', async () => { + const t = await pathTransformTranspile(` + import { Foo } from "@namespace/subdir"; + + export function fooUtil(foo: Foo): Foo { + return foo + } + `); + + expect(t.declarationOutputText).toBe( + 'import { Foo } from "./name/space/subdir";export declare function fooUtil(foo: Foo): Foo;' + ); + }); + + it('should rewrite multiple aliased paths in the same module', async () => { + const t = await pathTransformTranspile(` + import { Foo } from "@namespace/subdir"; + import { Bar } from "@namespace"; + + export function fooUtil(foo: Foo): Bar { + return foo.toBar() + } + `); + + expect(t.declarationOutputText).toBe( + 'import { Foo } from "./name/space/subdir";import { Bar } from "./name/space";export declare function fooUtil(foo: Foo): Bar;' + ); + }); + + it('should rewrite aliased paths while leaving non-aliased paths alone', async () => { + const t = await pathTransformTranspile(` + import { Foo } from "@namespace/subdir"; + import { Bar } from "./name/space"; + + export function fooUtil(foo: Foo): Bar { + return foo.toBar() + } + `); + + expect(t.declarationOutputText).toBe( + 'import { Foo } from "./name/space/subdir";import { Bar } from "./name/space";export declare function fooUtil(foo: Foo): Bar;' + ); + }); +}); diff --git a/src/compiler/transformers/test/transpile.ts b/src/compiler/transformers/test/transpile.ts index bdb6d022ca6..1a88d3fedfd 100644 --- a/src/compiler/transformers/test/transpile.ts +++ b/src/compiler/transformers/test/transpile.ts @@ -15,6 +15,10 @@ import { getScriptTarget } from '../transform-utils'; * @param compilerCtx a compiler context to use in the transpilation process * @param beforeTransformers TypeScript transformers that should be applied before the code is emitted * @param afterTransformers TypeScript transformers that should be applied after the code is emitted + * @param afterDeclarations TypeScript transformers that should be applied + * after declarations are generated + * @param tsConfig optional typescript compiler options to use + * @param inputFileName a dummy filename to use for the module (defaults to `module.tsx`) * @returns the result of the transpilation step */ export function transpileModule( @@ -22,46 +26,57 @@ export function transpileModule( config?: d.Config | null, compilerCtx?: d.CompilerCtx | null, beforeTransformers: ts.TransformerFactory[] = [], - afterTransformers: ts.TransformerFactory[] = [] + afterTransformers: ts.TransformerFactory[] = [], + afterDeclarations: ts.TransformerFactory[] = [], + tsConfig: ts.CompilerOptions = {}, + inputFileName = 'module.tsx' ) { - const options = ts.getDefaultCompilerOptions(); - options.isolatedModules = true; - options.suppressOutputPathCheck = true; - options.allowNonTsExtensions = true; - options.removeComments = false; - options.noLib = true; - options.lib = undefined; - options.types = undefined; - options.noEmit = undefined; - options.noEmitOnError = undefined; - options.noEmitHelpers = true; - options.paths = undefined; - options.rootDirs = undefined; - options.declaration = undefined; - options.composite = undefined; - options.declarationDir = undefined; - options.out = undefined; - options.outFile = undefined; - options.noResolve = true; - - options.module = ts.ModuleKind.ESNext; - options.target = getScriptTarget(); - options.experimentalDecorators = true; + const options: ts.CompilerOptions = { + ...ts.getDefaultCompilerOptions(), + allowNonTsExtensions: true, + composite: undefined, + declaration: undefined, + declarationDir: undefined, + experimentalDecorators: true, + isolatedModules: true, + jsx: ts.JsxEmit.React, + jsxFactory: 'h', + jsxFragmentFactory: 'Fragment', + lib: undefined, + module: ts.ModuleKind.ESNext, + noEmit: undefined, + noEmitHelpers: true, + noEmitOnError: undefined, + noLib: true, + noResolve: true, + out: undefined, + outFile: undefined, + paths: undefined, + removeComments: false, + rootDirs: undefined, + suppressOutputPathCheck: true, + target: getScriptTarget(), + types: undefined, + // add in possible default config overrides + ...tsConfig, + }; - options.jsx = ts.JsxEmit.React; - options.jsxFactory = 'h'; - options.jsxFragmentFactory = 'Fragment'; + config = config || mockConfig(); + compilerCtx = compilerCtx || mockCompilerCtx(config); - const inputFileName = 'module.tsx'; const sourceFile = ts.createSourceFile(inputFileName, input, options.target); let outputText: string; + let declarationOutputText: string; const emitCallback: ts.WriteFileCallback = (emitFilePath, data, _w, _e, tsSourceFiles) => { if (emitFilePath.endsWith('.js')) { - outputText = data; + outputText = prettifyTSOutput(data); updateModule(config, compilerCtx, buildCtx, tsSourceFiles[0], data, emitFilePath, tsTypeChecker, null); } + if (emitFilePath.endsWith('.d.ts')) { + declarationOutputText = prettifyTSOutput(data); + } }; const compilerHost: ts.CompilerHost = { @@ -81,9 +96,6 @@ export function transpileModule( const tsProgram = ts.createProgram([inputFileName], options, compilerHost); const tsTypeChecker = tsProgram.getTypeChecker(); - config = config || mockConfig(); - compilerCtx = compilerCtx || mockCompilerCtx(config); - const buildCtx = mockBuildCtx(config, compilerCtx); const transformOpts: d.TransformOptions = { @@ -102,12 +114,9 @@ export function transpileModule( convertStaticToMeta(config, compilerCtx, buildCtx, tsTypeChecker, null, transformOpts), ...afterTransformers, ], + afterDeclarations, }); - while (outputText.includes(' ')) { - outputText = outputText.replace(/ /g, ' '); - } - const moduleFile: d.Module = compilerCtx.moduleMap.values().next().value; const cmps = moduleFile ? moduleFile.cmps : null; const cmp = Array.isArray(cmps) && cmps.length > 0 ? cmps[0] : null; @@ -129,32 +138,43 @@ export function transpileModule( const legacyContext = cmp ? cmp.legacyContext : null; return { - outputText, - compilerCtx, buildCtx, - diagnostics: buildCtx.diagnostics, - moduleFile, - cmps, cmp, + cmps, + compilerCtx, componentClassName, - tagName, + declarationOutputText, + diagnostics: buildCtx.diagnostics, + elementRef, + event, + events, + legacyConnect, + legacyContext, + listener, + listeners, + method, + methods, + moduleFile, + outputText, properties, - virtualProperties, property, - states, state, - listeners, - listener, - events, - event, - methods, - method, - elementRef, - legacyContext, - legacyConnect, + states, + tagName, + virtualProperties, }; } +/** + * Rewrites any stretches of whitespace in the TypeScript output to take up a + * single space instead. This makes it a little more readable to write out strings + * in spec files for comparison. + * + * @param tsOutput the string to process + * @returns that string with any stretches of whitespace shrunk down to one space + */ +const prettifyTSOutput = (tsOutput: string): string => tsOutput.replace(/\s+/gm, ' '); + export function getStaticGetter(output: string, prop: string) { const toEvaluate = `return ${output.replace('export', '')}`; try { diff --git a/src/compiler/transpile/run-program.ts b/src/compiler/transpile/run-program.ts index d3c0d48419f..8663566ce66 100644 --- a/src/compiler/transpile/run-program.ts +++ b/src/compiler/transpile/run-program.ts @@ -1,12 +1,16 @@ 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 +54,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/compiler/transpile/transpile-module.ts b/src/compiler/transpile/transpile-module.ts index 83edf35f046..32611d47ac7 100644 --- a/src/compiler/transpile/transpile-module.ts +++ b/src/compiler/transpile/transpile-module.ts @@ -9,6 +9,10 @@ import { createLogger } from '../sys/logger/console-logger'; import { lazyComponentTransform } from '../transformers/component-lazy/transform-lazy-component'; import { nativeComponentTransform } from '../transformers/component-native/tranform-to-native-component'; import { convertDecoratorsToStatic } from '../transformers/decorators-to-static/convert-decorators'; +import { + rewriteAliasedDTSImportPaths, + rewriteAliasedSourceFileImportPaths, +} from '../transformers/rewrite-aliased-paths'; import { convertStaticToMeta } from '../transformers/static-to-meta/visitor'; import { updateStencilCoreImports } from '../transformers/update-stencil-core-import'; @@ -104,23 +108,39 @@ export const transpileModule = ( const program = ts.createProgram([sourceFilePath], tsCompilerOptions, compilerHost); const typeChecker = program.getTypeChecker(); - const after: ts.TransformerFactory[] = [ - convertStaticToMeta(config, compilerCtx, buildCtx, typeChecker, null, transformOpts), - ]; + const transformers: ts.CustomTransformers = { + before: [ + convertDecoratorsToStatic(config, buildCtx.diagnostics, typeChecker), + updateStencilCoreImports(transformOpts.coreImportPath), + ], + after: [convertStaticToMeta(config, compilerCtx, buildCtx, typeChecker, null, transformOpts)], + 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); + } if (transformOpts.componentExport === 'customelement' || transformOpts.componentExport === 'module') { - after.push(nativeComponentTransform(compilerCtx, transformOpts)); + transformers.after.push(nativeComponentTransform(compilerCtx, transformOpts)); } else { - after.push(lazyComponentTransform(compilerCtx, transformOpts)); + transformers.after.push(lazyComponentTransform(compilerCtx, transformOpts)); } - program.emit(undefined, undefined, undefined, false, { - before: [ - convertDecoratorsToStatic(config, buildCtx.diagnostics, typeChecker), - updateStencilCoreImports(transformOpts.coreImportPath), - ], - after, - }); + program.emit(undefined, undefined, undefined, false, transformers); const tsDiagnostics = [...program.getSyntacticDiagnostics()]; diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 89849867ae3..71432d03225 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. @@ -2491,6 +2500,9 @@ export interface CompilerRequestResponse { status: number; } +/** + * Options for Stencil's string-to-string transpiler + */ export interface TranspileOptions { /** * A component can be defined as a custom element by using `customelement`, or the @@ -2567,6 +2579,11 @@ export interface TranspileOptions { * Passed in Stencil Compiler System, otherwise falls back to the internal in-memory only system. */ sys?: CompilerSystem; + /** + * This option enables the same behavior as {@link Config.transformAliasedImportPaths}, transforming paths aliased in + * `tsconfig.json` to relative paths. + */ + transformAliasedImportPaths?: boolean; } export type CompileTarget = diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index 6dd5187e9a4..7ad0e7c986e 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, }; }