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`.

This also implements support for transforming `paths` aliases in Stencil's
string-to-string transpiler.
  • Loading branch information
alicewriteswrongs committed Feb 21, 2023
1 parent cf1538b commit abcaada
Show file tree
Hide file tree
Showing 15 changed files with 534 additions and 77 deletions.
1 change: 1 addition & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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
29 changes: 23 additions & 6 deletions src/compiler/config/transpile-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
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
21 changes: 20 additions & 1 deletion src/compiler/sys/typescript/typescript-resolve-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/compiler/sys/typescript/typescript-sys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
173 changes: 173 additions & 0 deletions src/compiler/transformers/rewrite-aliased-paths.ts
Original file line number Diff line number Diff line change
@@ -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<ts.Bundle | ts.SourceFile> {
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<ts.SourceFile> {
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<ts.Node> => {
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
);
}
Loading

0 comments on commit abcaada

Please sign in to comment.