From aa1778061181c3e26d7f228087ae4c62d922f90a Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 17 Oct 2025 16:20:14 +0200 Subject: [PATCH] implement the remapSpecifiers option --- README.md | 49 +++-- index.ts | 183 +++++++++++++++--- tests/supported/cases/option-imports/input.ts | 5 + .../supported/cases/option-imports/options.ts | 13 ++ .../supported/cases/option-imports/output.js | 3 + 5 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 tests/supported/cases/option-imports/input.ts create mode 100644 tests/supported/cases/option-imports/options.ts create mode 100644 tests/supported/cases/option-imports/output.js diff --git a/README.md b/README.md index bf86504..9233ffc 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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; @@ -77,17 +96,7 @@ class Person { } ``` -### Options - -
-
removeComments?: boolean
-
Whether to strip comments
-
Default false
- -
pathRewriting?: boolean
-
Whether to rewrite .ts module imports to .js imports
-
Default false
-
+### [Options](https://jsr.io/@fcrozatier/type-strip/doc/~/TypeStripOptions) ## Unsupported features diff --git a/index.ts b/index.ts index eefe68d..8555622 100644 --- a/index.ts +++ b/index.ts @@ -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; @@ -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; + }; + /** + * @deprecated Use pathRewriting instead + */ + tsToJsModuleSpecifiers?: boolean; }; -const defaults: Required = { - 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 @@ -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) { @@ -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; @@ -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); + } + } } } }; diff --git a/tests/supported/cases/option-imports/input.ts b/tests/supported/cases/option-imports/input.ts new file mode 100644 index 0000000..3292037 --- /dev/null +++ b/tests/supported/cases/option-imports/input.ts @@ -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"; \ No newline at end of file diff --git a/tests/supported/cases/option-imports/options.ts b/tests/supported/cases/option-imports/options.ts new file mode 100644 index 0000000..42bd24e --- /dev/null +++ b/tests/supported/cases/option-imports/options.ts @@ -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", + }, + }, +}; diff --git a/tests/supported/cases/option-imports/output.js b/tests/supported/cases/option-imports/output.js new file mode 100644 index 0000000..aa8bbdc --- /dev/null +++ b/tests/supported/cases/option-imports/output.js @@ -0,0 +1,3 @@ +import a from "../foo/bar.js"; +import { b } from "../fiz/bar.js"; +import { c, } from "@baz/bar"; \ No newline at end of file