Skip to content

Commit

Permalink
feat(compiler): transform module aliases in emitted js, typedefs
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
alicewriteswrongs committed Feb 16, 2023
1 parent ad6b238 commit 4aa9e5a
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 5 deletions.
1 change: 1 addition & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const runTask = async (
rootDir,
sys: configSys,
testing: config.testing ?? {},
transformAliasedImportPaths: config.transformAliasedImportPaths ?? false,
};

switch (task) {
Expand Down
13 changes: 13 additions & 0 deletions src/compiler/config/test/validate-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/compiler/config/test/validate-service-worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('validateServiceWorker', () => {
rootDir: '/',
sys: mockCompilerSystem(),
testing: {},
transformAliasedImportPaths: false,
};
});

Expand Down
1 change: 1 addition & 0 deletions src/compiler/config/transpile-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/compiler/config/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/compiler/sys/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
152 changes: 152 additions & 0 deletions src/compiler/transformers/rewrite-aliased-paths.ts
Original file line number Diff line number Diff line change
@@ -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<ts.Bundle | ts.SourceFile> {
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<ts.SourceFile> => {
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<ts.Node> => {
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);
};
};
29 changes: 25 additions & 4 deletions src/compiler/transpile/run-program.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'));
Expand Down
11 changes: 10 additions & 1 deletion src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/testing/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function mockValidatedConfig(overrides: Partial<ValidatedConfig> = {}): V
rootDir,
sys: createTestingSystem(),
testing: {},
transformAliasedImportPaths: false,
...overrides,
};
}
Expand Down

0 comments on commit 4aa9e5a

Please sign in to comment.