From 7bccf68ef6c92b6e074924be7e5cf01a60963b4f Mon Sep 17 00:00:00 2001 From: Alice Date: Tue, 21 Feb 2023 11:25:37 -0500 Subject: [PATCH] feat(compiler): transform module aliases in emitted js, typedefs This implements a transformer which is very similar to the one added in #3523 but which is run earlier in the build process and which can therefore rewrite aliased paths in both emitted JS and typedef files. This matters if the user has the `generateTypeDeclarations` option set to `true` on one of their output targets. The new behavior implemented here, however, is no longer specific to a particular output target, and applies to all TypeScript code which runs through Stencil. Accordingly, the behavior is opt-in, and is controlled by a new configuration value, `transformAliasedImportPaths` which defaults to `false`. This also implements support for transforming `paths` aliases in Stencil's string-to-string transpiler. --- src/cli/run.ts | 1 + .../config/test/validate-config.spec.ts | 13 ++ .../test/validate-service-worker.spec.ts | 1 + src/compiler/config/transpile-options.ts | 29 ++- src/compiler/config/validate-config.ts | 1 + src/compiler/sys/config.ts | 1 + .../typescript/typescript-resolve-module.ts | 21 ++- src/compiler/sys/typescript/typescript-sys.ts | 2 + .../transformers/rewrite-aliased-paths.ts | 173 ++++++++++++++++++ .../test/rewrite-aliased-paths.spec.ts | 147 +++++++++++++++ src/compiler/transformers/test/transpile.ts | 126 +++++++------ src/compiler/transpile/run-program.ts | 32 +++- src/compiler/transpile/transpile-module.ts | 44 +++-- src/declarations/stencil-public-compiler.ts | 19 +- src/testing/mocks.ts | 1 + 15 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 src/compiler/transformers/rewrite-aliased-paths.ts create mode 100644 src/compiler/transformers/test/rewrite-aliased-paths.spec.ts 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, }; }