Skip to content

Commit

Permalink
feat(typescript): add transformers factory
Browse files Browse the repository at this point in the history
  • Loading branch information
Haringat committed Jan 24, 2024
1 parent 2a19079 commit 2d2b656
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 14 deletions.
44 changes: 43 additions & 1 deletion packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ typescript({

### `transformers`

Type: `{ [before | after | afterDeclarations]: TransformerFactory[] }`<br>
Type: `{ [before | after | afterDeclarations]: TransformerFactory[] } | ((program: ts.Program) => ts.CustomTransformers)`<br>
Default: `undefined`

Allows registration of TypeScript custom transformers at any of the supported stages:
Expand Down Expand Up @@ -199,6 +199,48 @@ typescript({
});
```

Alternatively, the transformers can be created inside a factory.

Supported transformer factories:

- all **built-in** TypeScript custom transformer factories:

- `import('typescript').TransformerFactory` annotated **TransformerFactory** bellow
- `import('typescript').CustomTransformerFactory` annotated **CustomTransformerFactory** bellow

The example above could be written like this:

```js
typescript({
transformers: function (program) {
return {
before: [
ProgramRequiringTransformerFactory(program),
TypeCheckerRequiringTransformerFactory(program.getTypeChecker())
],
after: [
// You can use normal transformers directly
require('custom-transformer-based-on-Context')
],
afterDeclarations: [
// Or even define in place
function fixDeclarationFactory(context) {
return function fixDeclaration(source) {
function visitor(node) {
// Do real work here

return ts.visitEachChild(node, visitor, context);
}

return ts.visitEachChild(source, visitor, context);
};
}
]
};
}
});
```

### `cacheDir`

Type: `String`<br>
Expand Down
4 changes: 3 additions & 1 deletion packages/typescript/src/moduleResolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export type Resolver = (

/**
* Create a helper for resolving modules using Typescript.
* @param host Typescript host that extends `ModuleResolutionHost`
* @param ts custom typescript implementation
* @param host Typescript host that extends {@link ModuleResolutionHost}
* @param filter
* with methods for sanitizing filenames and getting compiler options.
*/
export default function createModuleResolver(
Expand Down
7 changes: 4 additions & 3 deletions packages/typescript/src/options/tsconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function makeForcedCompilerOptions(noForceEmit: boolean) {

/**
* Finds the path to the tsconfig file relative to the current working directory.
* @param ts Custom typescript implementation
* @param relativePath Relative tsconfig path given by the user.
* If `false` is passed, then a null path is returned.
* @returns The absolute path, or null if the file does not exist.
Expand All @@ -69,9 +70,8 @@ function getTsConfigPath(ts: typeof typescript, relativePath?: string | false) {

/**
* Tries to read the tsconfig file at `tsConfigPath`.
* @param ts Custom typescript implementation
* @param tsConfigPath Absolute path to tsconfig JSON file.
* @param explicitPath If true, the path was set by the plugin user.
* If false, the path was computed automatically.
*/
function readTsConfigFile(ts: typeof typescript, tsConfigPath: string) {
const { config, error } = ts.readConfigFile(tsConfigPath, (path) => readFileSync(path, 'utf8'));
Expand Down Expand Up @@ -122,13 +122,14 @@ function setModuleResolutionKind(parsedConfig: ParsedCommandLine): ParsedCommand
};
}

const configCache = new Map() as typescript.Map<ExtendedConfigCacheEntry>;
const configCache = new Map() as typescript.ESMap<string, ExtendedConfigCacheEntry>;

/**
* Parse the Typescript config to use with the plugin.
* @param ts Typescript library instance.
* @param tsconfig Path to the tsconfig file, or `false` to ignore the file.
* @param compilerOptions Options passed to the plugin directly for Typescript.
* @param noForceEmit Whether to respect emit options from {@link tsconfig}
*
* @returns Parsed tsconfig.json file with some important properties:
* - `options`: Parsed compiler options.
Expand Down
32 changes: 24 additions & 8 deletions packages/typescript/src/watchProgram.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { PluginContext } from 'rollup';
import typescript from 'typescript';
import type {
CustomTransformers,
Diagnostic,
EmitAndSemanticDiagnosticsBuilderProgram,
ParsedCommandLine,
Program,
WatchCompilerHostOfFilesAndCompilerOptions,
WatchStatusReporter,
WriteFileCallback
Expand Down Expand Up @@ -39,7 +41,7 @@ interface CreateProgramOptions {
/** Function to resolve a module location */
resolveModule: Resolver;
/** Custom TypeScript transformers */
transformers?: CustomTransformerFactories;
transformers?: CustomTransformerFactories | ((program: Program) => CustomTransformers);
}

type DeferredResolve = ((value: boolean | PromiseLike<boolean>) => void) | (() => void);
Expand Down Expand Up @@ -155,22 +157,36 @@ function createWatchHost(
parsedOptions.projectReferences
);

let createdTransformers: CustomTransformers | undefined;
return {
...baseHost,
/** Override the created program so an in-memory emit is used */
afterProgramCreate(program) {
const origEmit = program.emit;
// eslint-disable-next-line no-param-reassign
program.emit = (targetSourceFile, _, ...args) =>
origEmit(
program.emit = (
targetSourceFile,
_,
cancellationToken,
emitOnlyDtsFiles,
customTransformers
) => {
createdTransformers ??=
typeof transformers === 'function'
? transformers(program.getProgram())
: mergeTransformers(
program,
transformers,
customTransformers as CustomTransformerFactories
);
return origEmit(
targetSourceFile,
writeFile,
// cancellationToken
args[0],
// emitOnlyDtsFiles
args[1],
mergeTransformers(program, transformers, args[2] as CustomTransformerFactories)
cancellationToken,
emitOnlyDtsFiles,
createdTransformers
);
};

return baseHost.afterProgramCreate!(program);
},
Expand Down
113 changes: 113 additions & 0 deletions packages/typescript/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,119 @@ test('supports custom transformers', async (t) => {
);
});

test('supports passing a custom transformers factory', async (t) => {
const warnings = [];

let program = null;
let typeChecker = null;

const bundle = await rollup({
input: 'fixtures/transformers/main.ts',
plugins: [
typescript({
tsconfig: 'fixtures/transformers/tsconfig.json',
outDir: 'fixtures/transformers/dist',
declaration: true,
transformers: (p) => {
program = p;
typeChecker = p.getTypeChecker();
return {
before: [
function removeOneParameterFactory(context) {
return function removeOneParameter(source) {
function visitor(node) {
if (ts.isArrowFunction(node)) {
return ts.factory.createArrowFunction(
node.modifiers,
node.typeParameters,
[node.parameters[0]],
node.type,
node.equalsGreaterThanToken,
node.body
);
}

return ts.visitEachChild(node, visitor, context);
}

return ts.visitEachChild(source, visitor, context);
};
}
],
after: [
// Enforce a constant numeric output
function enforceConstantReturnFactory(context) {
return function enforceConstantReturn(source) {
function visitor(node) {
if (ts.isReturnStatement(node)) {
return ts.factory.createReturnStatement(ts.factory.createNumericLiteral('1'));
}

return ts.visitEachChild(node, visitor, context);
}

return ts.visitEachChild(source, visitor, context);
};
}
],
afterDeclarations: [
// Change the return type to numeric
function fixDeclarationFactory(context) {
return function fixDeclaration(source) {
function visitor(node) {
if (ts.isFunctionTypeNode(node)) {
return ts.factory.createFunctionTypeNode(
node.typeParameters,
[node.parameters[0]],
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword)
);
}

return ts.visitEachChild(node, visitor, context);
}

return ts.visitEachChild(source, visitor, context);
};
}
]
};
}
})
],
onwarn(warning) {
warnings.push(warning);
}
});

const output = await getCode(bundle, { format: 'esm', dir: 'fixtures/transformers' }, true);

t.is(warnings.length, 0);
t.deepEqual(
output.map((out) => out.fileName),
['main.js', 'dist/main.d.ts']
);

// Expect the function to have one less arguments from before transformer and return 1 from after transformer
t.true(output[0].code.includes('var HashFn = function (val) { return 1; };'), output[0].code);

// Expect the definition file to reflect the resulting function type after transformer modifications
t.true(
output[1].source.includes('export declare const HashFn: (val: string) => number;'),
output[1].source
);

// Expect a Program to have been forwarded for transformers with custom factories requesting one
t.deepEqual(program && program.emit && typeof program.emit === 'function', true);

// Expect a TypeChecker to have been forwarded for transformers with custom factories requesting one
t.deepEqual(
typeChecker &&
typeChecker.getTypeAtLocation &&
typeof typeChecker.getTypeAtLocation === 'function',
true
);
});

// This test randomly fails with a segfault directly at the first "await waitForWatcherEvent" before any event occurred.
// Skipping it until we can figure out what the cause is.
test.serial.skip('picks up on newly included typescript files in watch mode', async (t) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface RollupTypescriptPluginOptions {
/**
* TypeScript custom transformers
*/
transformers?: CustomTransformerFactories;
transformers?: CustomTransformerFactories | ((program: Program) => CustomTransformers);
/**
* When set to false, force non-cached files to always be emitted in the output directory.output
* If not set, will default to true with a warning.
Expand Down

0 comments on commit 2d2b656

Please sign in to comment.