fix: rewrite .ts to .js when rewriteRelativeImportExtensions enabled (svelte-package...
authorjyc.dev <jycouet@gmail.com>
Thu, 20 Nov 2025 19:21:26 +0000 (20:21 +0100)
committerGitHub <noreply@github.com>
Thu, 20 Nov 2025 19:21:26 +0000 (20:21 +0100)
* add a failing test

* js all the way

* implem

* add changeset

* reuse import regex, align logic with rewriteRelativeImportExtensions setting

* perf: cache tsconfig lookup

* add rewriteRelativeImportExtensions

* consolidate tests

* tweak

* Update .changeset/fresh-pants-camp.md

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
24 files changed:
.changeset/fresh-pants-camp.md [new file with mode: 0644]
packages/package/src/index.js
packages/package/src/typescript.js
packages/package/src/utils.js
packages/package/test/fixtures/typescript-alias-rewrites/expected/index.d.ts [deleted file]
packages/package/test/fixtures/typescript-alias-rewrites/expected/index.js [deleted file]
packages/package/test/fixtures/typescript-alias-rewrites/src/lib/index.ts [deleted file]
packages/package/test/fixtures/typescript-alias-rewrites/svelte.config.js [deleted file]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte.d.ts [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper.d.ts [moved from packages/package/test/fixtures/typescript-alias-rewrites/expected/helper.d.ts with 100% similarity]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper.js [moved from packages/package/test/fixtures/typescript-alias-rewrites/expected/helper.js with 100% similarity]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.d.ts [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.js [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.d.ts [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.js [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/package.json [moved from packages/package/test/fixtures/typescript-alias-rewrites/package.json with 58% similarity]
packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/Demo.svelte [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/helper.ts [moved from packages/package/test/fixtures/typescript-alias-rewrites/src/lib/helper.ts with 100% similarity]
packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/helper2.ts [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/index.ts [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/svelte.config.js [new file with mode: 0644]
packages/package/test/fixtures/typescript-ts-extension-rewrites/tsconfig.json [moved from packages/package/test/fixtures/typescript-alias-rewrites/tsconfig.json with 100% similarity]
packages/package/test/index.spec.js

diff --git a/.changeset/fresh-pants-camp.md b/.changeset/fresh-pants-camp.md
new file mode 100644 (file)
index 0000000..4ad1336
--- /dev/null
@@ -0,0 +1,5 @@
+---
+'@sveltejs/package': patch
+---
+
+fix: transform `.ts` extensions to `.js` in import/export statements of Svelte files when using `rewriteRelativeImportExtensions`
index 8d66e5fdb914cdea763c6260423b751e81dd00c9..b91aa92a2b9374ef1def61545690fc5304a40a3f 100644 (file)
@@ -3,9 +3,16 @@ import * as path from 'node:path';
 import colors from 'kleur';
 import chokidar from 'chokidar';
 import { preprocess } from 'svelte/compiler';
-import { copy, mkdirp, rimraf } from './filesystem.js';
-import { analyze, resolve_aliases, scan, strip_lang_tags, write } from './utils.js';
-import { emit_dts, transpile_ts } from './typescript.js';
+import { copy, mkdirp, posixify, rimraf } from './filesystem.js';
+import {
+       analyze,
+       resolve_aliases,
+       resolve_ts_endings,
+       scan,
+       strip_lang_tags,
+       write
+} from './utils.js';
+import { emit_dts, load_tsconfig, transpile_ts } from './typescript.js';
 import { create_validator } from './validate.js';
 
 /**
@@ -37,8 +44,20 @@ async function do_build(options, analyse_code) {
                await emit_dts(input, temp, output, options.cwd, alias, files, tsconfig);
        }
 
+       /** @type {Map<string, import('typescript').CompilerOptions>} */
+       const tsconfig_cache = new Map();
+
        for (const file of files) {
-               await process_file(input, temp, file, options.config.preprocess, alias, tsconfig, analyse_code);
+               await process_file(
+                       input,
+                       temp,
+                       file,
+                       options.config.preprocess,
+                       alias,
+                       tsconfig,
+                       analyse_code,
+                       tsconfig_cache
+               );
        }
 
        if (!options.preserve_output) {
@@ -80,6 +99,9 @@ export async function watch(options) {
        /** @type {NodeJS.Timeout} */
        let timeout;
 
+       /** @type {Map<string, import('typescript').CompilerOptions>} */
+       const tsconfig_cache = new Map();
+
        const watcher = chokidar.watch(input, { ignoreInitial: true });
        /** @type {Promise<void>} */
        const ready = new Promise((resolve) => watcher.on('ready', resolve));
@@ -89,6 +111,14 @@ export async function watch(options) {
 
                pending.push({ file, type });
 
+               if (
+                       file.name.endsWith('tsconfig.json') ||
+                       file.name.endsWith('jsconfig.json') ||
+                       (options.tsconfig && posixify(filepath) === posixify(options.tsconfig))
+               ) {
+                       tsconfig_cache.clear();
+               }
+
                clearTimeout(timeout);
                timeout = setTimeout(async () => {
                        const files = scan(input, extensions);
@@ -130,7 +160,8 @@ export async function watch(options) {
                                                        options.config.preprocess,
                                                        alias,
                                                        tsconfig,
-                                                       analyse_code
+                                                       analyse_code,
+                                                       tsconfig_cache
                                                );
                                        } catch (e) {
                                                errored = true;
@@ -209,8 +240,18 @@ function normalize_options(options) {
  * @param {Record<string, string>} aliases
  * @param {string | undefined} tsconfig
  * @param {(name: string, code: string) => void} analyse_code
+ * @param {Map<string, import('typescript').CompilerOptions>} tsconfig_cache
  */
-async function process_file(input, output, file, preprocessor, aliases, tsconfig, analyse_code) {
+async function process_file(
+       input,
+       output,
+       file,
+       preprocessor,
+       aliases,
+       tsconfig,
+       analyse_code,
+       tsconfig_cache
+) {
        const filename = path.join(input, file.name);
        const dest = path.join(output, file.dest);
 
@@ -229,7 +270,13 @@ async function process_file(input, output, file, preprocessor, aliases, tsconfig
                contents = resolve_aliases(input, file.name, contents, aliases);
 
                if (file.name.endsWith('.ts') && !file.name.endsWith('.d.ts')) {
-                       contents = await transpile_ts(tsconfig, filename, contents);
+                       contents = await transpile_ts(tsconfig, filename, contents, tsconfig_cache);
+               } else if (file.is_svelte) {
+                       const options = await load_tsconfig(tsconfig, filename, tsconfig_cache);
+                       // Mimic TypeScript's transpileModule behavior for Svelte files
+                       if (options.rewriteRelativeImportExtensions) {
+                               contents = resolve_ts_endings(contents);
+                       }
                }
 
                analyse_code(file.name, contents);
index 81c0f348e6358349c6ac11196f66ce34e9a766f2..1105f935aa9827e990bf3aa53a26c7f3193420ec 100644 (file)
@@ -94,10 +94,11 @@ export async function emit_dts(input, output, final_output, cwd, alias, files, t
  * @param {string | undefined} tsconfig
  * @param {string} filename
  * @param {string} source
+ * @param {Map<string, import('typescript').CompilerOptions>} cache
  */
-export async function transpile_ts(tsconfig, filename, source) {
+export async function transpile_ts(tsconfig, filename, source, cache) {
        const ts = await try_load_ts();
-       const options = load_tsconfig(tsconfig, filename, ts);
+       const options = await load_tsconfig(tsconfig, filename, cache, ts);
        // transpileModule treats NodeNext as CommonJS because it doesn't read the package.json. Therefore we need to override it.
        // Also see https://github.com/microsoft/TypeScript/issues/53022 (the filename workaround doesn't work).
        return ts.transpileModule(source, {
@@ -123,14 +124,29 @@ async function try_load_ts() {
 /**
  * @param {string | undefined} tsconfig
  * @param {string} filename
- * @param {import('typescript')} ts
+ * @param {Map<string, import('typescript').CompilerOptions>} cache
+ * @param {import('typescript')} [ts]
  */
-function load_tsconfig(tsconfig, filename, ts) {
+export async function load_tsconfig(tsconfig, filename, cache, ts) {
+       if (!ts) {
+               ts = await try_load_ts();
+       }
+
        let config_filename;
+       /** @type {string[]} */
+       const traversed_dirs = [];
 
        if (tsconfig) {
                if (fs.existsSync(tsconfig)) {
                        config_filename = tsconfig;
+
+                       const cached = cache.get(config_filename);
+                       if (cached) {
+                               return cached;
+                       } else {
+                               // This isn't really a dir, but it simplifies the caching logic
+                               traversed_dirs.push(config_filename);
+                       }
                } else {
                        throw new Error('Failed to locate provided tsconfig or jsconfig');
                }
@@ -140,6 +156,16 @@ function load_tsconfig(tsconfig, filename, ts) {
                // so we implement it ourselves
                let dir = filename;
                while (dir !== (dir = path.dirname(dir))) {
+                       const cached = cache.get(dir);
+                       if (cached) {
+                               for (const traversed of traversed_dirs) {
+                                       cache.set(traversed, cached);
+                               }
+                               return cached;
+                       }
+
+                       traversed_dirs.push(dir);
+
                        const tsconfig = path.join(dir, 'tsconfig.json');
                        const jsconfig = path.join(dir, 'jsconfig.json');
 
@@ -175,5 +201,10 @@ function load_tsconfig(tsconfig, filename, ts) {
                { sourceMap: false },
                config_filename
        );
+
+       for (const dir of traversed_dirs) {
+               cache.set(dir, options);
+       }
+
        return options;
 }
index a9e38aafe29995ac94fb35646d33abc72172870d..bdf3f92e82f4601adcccdb097567bbf5d164c341 100644 (file)
@@ -15,12 +15,7 @@ const is_svelte_5_plus = Number(VERSION.split('.')[0]) >= 5;
  * @returns {string}
  */
 export function resolve_aliases(input, file, content, aliases) {
-       /**
-        * @param {string} match
-        * @param {string} quote
-        * @param {string} import_path
-        */
-       const replace_import_path = (match, quote, import_path) => {
+       return adjust_imports(content, (import_path) => {
                for (const [alias, value] of Object.entries(aliases)) {
                        if (
                                import_path !== alias &&
@@ -33,7 +28,48 @@ export function resolve_aliases(input, file, content, aliases) {
                        const full_import_path = path.join(value, import_path.slice(alias.length));
                        let resolved = posixify(path.relative(path.dirname(full_path), full_import_path));
                        resolved = resolved.startsWith('.') ? resolved : './' + resolved;
-                       return match.replace(quote + import_path + quote, quote + resolved + quote);
+                       return resolved;
+               }
+               return import_path;
+       });
+}
+
+/**
+ * Replace .ts extensions with .js in relative import/export statements
+ *
+ * @param {string} content
+ * @returns {string}
+ */
+export function resolve_ts_endings(content) {
+       return adjust_imports(content, (import_path) => {
+               if (
+                       import_path[0] === '.' &&
+                       ((import_path[1] === '.' && import_path[2] === '/') || import_path[1] === '/') &&
+                       import_path.endsWith('.ts')
+               ) {
+                       return import_path.slice(0, -3) + '.js';
+               }
+               return import_path;
+       });
+}
+
+/**
+ * Adjust import paths
+ *
+ * @param {string} content
+ * @param {(import_path: string) => string} adjust
+ * @returns {string}
+ */
+export function adjust_imports(content, adjust) {
+       /**
+        * @param {string} match
+        * @param {string} quote
+        * @param {string} import_path
+        */
+       const replace_import_path = (match, quote, import_path) => {
+               const adjusted = adjust(import_path);
+               if (adjusted !== import_path) {
+                       return match.replace(quote + import_path + quote, quote + adjusted + quote);
                }
                return match;
        };
diff --git a/packages/package/test/fixtures/typescript-alias-rewrites/expected/index.d.ts b/packages/package/test/fixtures/typescript-alias-rewrites/expected/index.d.ts
deleted file mode 100644 (file)
index 2084a6d..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-import { helper } from "./helper.ts";
-export { helper };
diff --git a/packages/package/test/fixtures/typescript-alias-rewrites/expected/index.js b/packages/package/test/fixtures/typescript-alias-rewrites/expected/index.js
deleted file mode 100644 (file)
index aab5a9a..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-import { helper } from "./helper.js";
-export { helper };
diff --git a/packages/package/test/fixtures/typescript-alias-rewrites/src/lib/index.ts b/packages/package/test/fixtures/typescript-alias-rewrites/src/lib/index.ts
deleted file mode 100644 (file)
index 94afe21..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-import { helper } from '$lib/helper.ts';
-export { helper };
diff --git a/packages/package/test/fixtures/typescript-alias-rewrites/svelte.config.js b/packages/package/test/fixtures/typescript-alias-rewrites/svelte.config.js
deleted file mode 100644 (file)
index 6bd253b..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-import preprocess from 'svelte-preprocess';
-
-export default {
-       preprocess: preprocess()
-};
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte
new file mode 100644 (file)
index 0000000..d117477
--- /dev/null
@@ -0,0 +1,5 @@
+<script lang="ts">import { helper } from './helper.js';
+       import { helper2 } from './helper2.js';
+</script>
+
+{helper()}{helper2()}
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte.d.ts b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/Demo.svelte.d.ts
new file mode 100644 (file)
index 0000000..201d654
--- /dev/null
@@ -0,0 +1,18 @@
+interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
+    new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
+        $$bindings?: Bindings;
+    } & Exports;
+    (internal: unknown, props: {
+        $$events?: Events;
+        $$slots?: Slots;
+    }): Exports & {
+        $set?: any;
+        $on?: any;
+    };
+    z_$$bindings?: Bindings;
+}
+declare const Demo: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
+    [evt: string]: CustomEvent<any>;
+}, {}, {}, string>;
+type Demo = InstanceType<typeof Demo>;
+export default Demo;
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.d.ts b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.d.ts
new file mode 100644 (file)
index 0000000..f326003
--- /dev/null
@@ -0,0 +1 @@
+export declare const helper2: () => string;
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.js b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/helper2.js
new file mode 100644 (file)
index 0000000..b36bc1f
--- /dev/null
@@ -0,0 +1 @@
+export const helper2 = () => '';
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.d.ts b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.d.ts
new file mode 100644 (file)
index 0000000..3460604
--- /dev/null
@@ -0,0 +1,3 @@
+import { helper } from "./helper.ts";
+import { helper2 } from "./helper2.ts";
+export { helper, helper2 };
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.js b/packages/package/test/fixtures/typescript-ts-extension-rewrites/expected/index.js
new file mode 100644 (file)
index 0000000..5fdd859
--- /dev/null
@@ -0,0 +1,3 @@
+import { helper } from "./helper.js";
+import { helper2 } from "./helper2.js";
+export { helper, helper2 };
similarity index 58%
rename from packages/package/test/fixtures/typescript-alias-rewrites/package.json
rename to packages/package/test/fixtures/typescript-ts-extension-rewrites/package.json
index 00c471fa0f93283b57eeec8692142fc68c6ac9bf..0d560f3322db41f206a41c9f14971f44e3e4503e 100644 (file)
@@ -1,8 +1,8 @@
 {
-       "name": "typescript-alias-rewrites",
+       "name": "typescript-ts-extension-rewrites",
        "private": true,
        "version": "1.0.0",
-       "description": "typescript package with extension rewrites to aliased import paths",
+       "description": "typescript package with extension rewrites, including to aliased import paths",
        "type": "module",
        "peerDependencies": {
                "svelte": "^5.0.0"
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/Demo.svelte b/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/Demo.svelte
new file mode 100644 (file)
index 0000000..e6dd585
--- /dev/null
@@ -0,0 +1,6 @@
+<script lang="ts">
+       import { helper } from '$lib/helper.ts';
+       import { helper2 } from './helper2.ts';
+</script>
+
+{helper()}{helper2()}
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/helper2.ts b/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/helper2.ts
new file mode 100644 (file)
index 0000000..b36bc1f
--- /dev/null
@@ -0,0 +1 @@
+export const helper2 = () => '';
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/index.ts b/packages/package/test/fixtures/typescript-ts-extension-rewrites/src/lib/index.ts
new file mode 100644 (file)
index 0000000..8148854
--- /dev/null
@@ -0,0 +1,3 @@
+import { helper } from '$lib/helper.ts';
+import { helper2 } from './helper2.ts';
+export { helper, helper2 };
diff --git a/packages/package/test/fixtures/typescript-ts-extension-rewrites/svelte.config.js b/packages/package/test/fixtures/typescript-ts-extension-rewrites/svelte.config.js
new file mode 100644 (file)
index 0000000..ff8b4c5
--- /dev/null
@@ -0,0 +1 @@
+export default {};
index 228c40cc7026d232fab7d54d00c62de0e1585c1b..0dda339eba50f65d69bfb119ca5ffd293d20a550 100644 (file)
@@ -135,8 +135,8 @@ test('create package with typescript using nodenext', async () => {
        await test_make_package('typescript-nodenext');
 });
 
-test('create package with typescript alias extension rewrites', async () => {
-       await test_make_package('typescript-alias-rewrites');
+test('create package with .ts extension rewrites, including for aliases', async () => {
+       await test_make_package('typescript-ts-extension-rewrites');
 });
 
 // only run this test in newer Node versions