Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 29 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@

`Type-Strip` is a **super-fast** type-stripper: TypeScript code goes in, and JavaScript code with type annotations removed comes out.

It also ensures **forward compatibility** with the TC39 [Type Annotation Proposal](https://tc39.es/proposal-type-annotations/). This means that when the proposal reaches stage 4, you'll be able to seamlessly change your file extensions to `.js`, and won't need a transpilation step anymore.
It also ensures **forward compatibility** of your code with the TC39 [Type Annotation Proposal](https://tc39.es/proposal-type-annotations/).

If you're using modern TypeScript today, then `Type-Strip` might be the only build step you need.

## Features

- Strips type annotations
- Optionally strips comments and rewrites .ts imports. See the [options](#options) below
- (Optionally) strips comments
- (Optionally) rewrites .ts imports to .js
- (Optionally) remap aliases in import specifiers
- Fast. See the [benchmark](#benchmark)
- Throws when an [unsupported syntax](#unsupported-features) is detected.

See the [options](https://jsr.io/@fcrozatier/type-strip/doc/~/TypeStripOptions) for more

## Installation

Depending on your runtime / package-manager:
Expand All @@ -31,18 +35,18 @@ yarn dlx jsr add @fcrozatier/type-strip
Strip a string of code, files etc.

```ts
import strip from '@fcrozatier/type-strip';
import stripTypes from '@fcrozatier/type-strip';

console.log(strip("let num: number = 0;", {/* options */}));
//-> let num = 0;
stripTypes("let num: number = 0;", {/* options */});
// let num = 0;
```

### Example

Input
Input file `./example.ts`

```ts
import { capitalize } from './utils.ts';
import { capitalize } from '$utils/strings.ts';

/**
* This class implements a Person
Expand All @@ -61,10 +65,25 @@ class Person {
}
```

Output with the `removeComments` and `pathRewriting` options:
Options:

```ts
{
removeComments: true,
pathRewriting: true,
remapSpecifiers: {
filePath: "./example.ts",
imports: {
"$utils/": "./lib/utils/",
},
},
}
```

Output:

```ts
import { capitalize } from './utils.js';
import { capitalize } from './lib/utils/strings.js';

class Person {
name;
Expand All @@ -77,17 +96,7 @@ class Person {
}
```

### Options

<dl>
<dt><code>removeComments?: boolean</code></dt>
<dd>Whether to strip comments</dd>
<dd><em>Default</em> <code>false</code></dd>

<dt><code>pathRewriting?: boolean</code></dt>
<dd>Whether to rewrite <code>.ts</code> module imports to <code>.js</code> imports</dd>
<dd><em>Default</em> <code>false</code></dd>
</dl>
### [Options](https://jsr.io/@fcrozatier/type-strip/doc/~/TypeStripOptions)

## Unsupported features

Expand Down
183 changes: 157 additions & 26 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import ts from "typescript";
import { TypeStripError } from "./errors.ts";
import { relative } from "@std/path/relative";
import { dirname } from "@std/path/dirname";
import { isAbsolute } from "@std/path/is-absolute";

const SyntaxKind = ts.SyntaxKind;

Expand All @@ -9,32 +12,98 @@ const SyntaxKind = ts.SyntaxKind;
export type TypeStripOptions = {
/**
* Whether to strip comments
*
* @default false
*/
removeComments?: boolean;
/**
* @deprecated Use pathRewriting instead
*/
tsToJsModuleSpecifiers?: boolean;
/**
* Whether to rewrite .ts module imports to .js imports
*
* @default false
*/
pathRewriting?: boolean;
/**
* Whether and how to expand aliases in import specifiers
*/
remapSpecifiers?: {
/**
* The path of the file being stripped.
*/
filePath: string;
/**
* A map of specifiers to their remapped specifiers
*
* Relative paths for substitutions are specified with respect to the `deno.json` file
*
* @example
*
* Given the file structure:
*
* ```
* ├ deno.json
* ├ a.ts
* └ lib/
* └ foo/
* ├ b.ts
* └ bar.ts
* ```
*
* Where `a.ts` and `b.ts` both contain:
*
* ```ts
* "import '$foo/bar.ts'"
* ```
*
* With the option `{ imports: { '$foo/': "./lib/foo/" }` the aliases are substituted as expected:
*
* ```ts
* import stripTypes from '@fcrozatier/type-strip';
*
* stripTypes(fileA, {
* pathRewriting: true,
* remapSpecifiers: {
* filePath: './a.ts',
* imports: { '$foo/': "./lib/foo/" }
* }
* });
* // input: import '$foo/bar.ts'
* // output: import './lib/foo/bar.js'
*
* stripTypes(fileB, {
* pathRewriting: true,
* remapSpecifiers: {
* filePath: './lib/b.ts',
* imports: { '$foo/': "./lib/foo/" } });
* }
* // input: import '$foo/bar.ts'
* // output: import './bar.js'
* ```
*/
imports: Record<string, string>;
};
/**
* @deprecated Use pathRewriting instead
*/
tsToJsModuleSpecifiers?: boolean;
};

const defaults: Required<TypeStripOptions> = {
removeComments: false,
tsToJsModuleSpecifiers: false,
pathRewriting: false,
};

type StripItem = { start: number; end: number; trailing?: RegExp };

let sourceFile: ts.SourceFile;
let sourceCode: string;
let outputCode: string;
let removeComments: boolean;
let pathRewriting: boolean;
let strip: StripItem[] = [];
let filePath: string;
let imports: [string, string][] | undefined;

type StripItem = { start: number; end: number; trailing?: RegExp };
const strip: StripItem[] = [];

type TransformSpecifier = {
pos: number;
alias: RegExp;
replacement: string;
};
const transformSpecifiers: TransformSpecifier[] = [];

/**
* Takes a TypeScript input source file and outputs JavaScript with types stripped
Expand All @@ -53,26 +122,51 @@ export default (
ts.ScriptKind.TS,
);

removeComments = options?.removeComments ?? defaults.removeComments;
removeComments = options?.removeComments ?? false;
pathRewriting = (options?.pathRewriting || options?.tsToJsModuleSpecifiers) ??
defaults.pathRewriting;
false;
const remapSpecifiers = options?.remapSpecifiers;

if (remapSpecifiers) {
imports = Object.entries(remapSpecifiers.imports);
filePath = remapSpecifiers.filePath;
}

sourceCode = sourceFile.getFullText();
outputCode = "";
strip = [];
strip.length = 0;
transformSpecifiers.length = 0;

const statements = sourceFile.statements;
for (let i = 0; i < statements.length; i++) {
topLevelVisitor(statements[i]);
}

transformSpecifiers.reverse();
let currentSpecifierTransform = transformSpecifiers.pop();

let currentIndex = 0;
for (let i = 0; i < strip.length; i++) {
const { start, end, trailing } = strip[i];

if (start !== undefined && end !== undefined) {
if (currentIndex < start) {
outputCode += sourceCode.slice(currentIndex, start);
let chunk = sourceCode.slice(currentIndex, start);

while (
currentSpecifierTransform &&
currentSpecifierTransform.pos >= currentIndex &&
currentSpecifierTransform.pos < start
) {
chunk = chunk.replace(
currentSpecifierTransform.alias,
"$1" +
currentSpecifierTransform.replacement,
);
currentSpecifierTransform = transformSpecifiers.pop();
}

outputCode += chunk;
currentIndex = end;
}
if (currentIndex < end) {
Expand All @@ -87,7 +181,20 @@ export default (
}
}
if (currentIndex < sourceCode.length) {
outputCode += sourceCode.slice(currentIndex);
let chunk = sourceCode.slice(currentIndex);

while (
currentSpecifierTransform &&
currentSpecifierTransform.pos >= currentIndex
) {
chunk = chunk.replace(
currentSpecifierTransform.alias,
"$1" + currentSpecifierTransform.replacement,
);
currentSpecifierTransform = transformSpecifiers.pop();
}

outputCode += chunk;
}

return outputCode;
Expand Down Expand Up @@ -251,17 +358,41 @@ const visitImportDeclaration = (node: ts.ImportDeclaration) => {
strip.push({ start: node.pos, end: node.end });
} else if (node.importClause) {
const skipDeclaration = visitImportClause(node.importClause);

if (skipDeclaration) {
strip.push({ start: node.pos, end: node.end });
}
}
if (pathRewriting) {
if (ts.isStringLiteral(node.moduleSpecifier)) {
const moduleSpecifier = node.moduleSpecifier;
} else {
if (ts.isStringLiteral(node.moduleSpecifier)) {
const moduleSpecifier = node.moduleSpecifier;

if (imports) {
const match = imports.find(([alias]) =>
moduleSpecifier.text.startsWith(alias)
);

if (match) {
let replacement = match[1];

// relative path: non absolute path that's not an @alias
if (!isAbsolute(replacement) && replacement.startsWith(".")) {
replacement = relative(dirname(filePath), replacement) +
(replacement.endsWith("/") ? "/" : "");
}

transformSpecifiers.push({
pos: moduleSpecifier.pos,
alias: new RegExp("(['\"])" + RegExp.escape(match[0])),
replacement,
});
}
}

sourceCode = sourceCode.slice(0, moduleSpecifier.pos) +
` "${moduleSpecifier.text.replace(/\.ts$/, ".js")}"` +
sourceCode.slice(moduleSpecifier.end);
if (pathRewriting) {
sourceCode = sourceCode.slice(0, moduleSpecifier.pos) +
` "${moduleSpecifier.text.replace(/\.ts$/, ".js")}"` +
sourceCode.slice(moduleSpecifier.end);
}
}
}
}
};
Expand Down
5 changes: 5 additions & 0 deletions tests/supported/cases/option-imports/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import a from "$foo/bar.ts";
import type A from "$foo/bar.d.ts";
import { b } from "$fiz/bar.ts";
import { type B } from "$foo/bar.d.ts";
import { c, type C } from "$baz/bar";
13 changes: 13 additions & 0 deletions tests/supported/cases/option-imports/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { TypeStripOptions } from "@fcrozatier/type-strip";

export const options: TypeStripOptions = {
pathRewriting: true,
remapSpecifiers: {
filePath: "./lib/a.ts",
imports: {
"$foo/": "./foo/",
"$fiz": "./fiz",
"$baz": "@baz",
},
},
};
3 changes: 3 additions & 0 deletions tests/supported/cases/option-imports/output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import a from "../foo/bar.js";
import { b } from "../fiz/bar.js";
import { c, } from "@baz/bar";