Skip to content

Commit fa32cc0

Browse files
jycouetdummdidumm
andauthored
fix: rewrite .ts to .js when rewriteRelativeImportExtensions enabled (svelte-package) (#14936)
* 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 <[email protected]> Co-authored-by: Simon Holthausen <[email protected]>
1 parent 88d44e4 commit fa32cc0

File tree

24 files changed

+183
-33
lines changed

24 files changed

+183
-33
lines changed

.changeset/fresh-pants-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/package': patch
3+
---
4+
5+
fix: transform `.ts` extensions to `.js` in import/export statements of Svelte files when using `rewriteRelativeImportExtensions`

packages/package/src/index.js

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import * as path from 'node:path';
33
import colors from 'kleur';
44
import chokidar from 'chokidar';
55
import { preprocess } from 'svelte/compiler';
6-
import { copy, mkdirp, rimraf } from './filesystem.js';
7-
import { analyze, resolve_aliases, scan, strip_lang_tags, write } from './utils.js';
8-
import { emit_dts, transpile_ts } from './typescript.js';
6+
import { copy, mkdirp, posixify, rimraf } from './filesystem.js';
7+
import {
8+
analyze,
9+
resolve_aliases,
10+
resolve_ts_endings,
11+
scan,
12+
strip_lang_tags,
13+
write
14+
} from './utils.js';
15+
import { emit_dts, load_tsconfig, transpile_ts } from './typescript.js';
916
import { create_validator } from './validate.js';
1017

1118
/**
@@ -37,8 +44,20 @@ async function do_build(options, analyse_code) {
3744
await emit_dts(input, temp, output, options.cwd, alias, files, tsconfig);
3845
}
3946

47+
/** @type {Map<string, import('typescript').CompilerOptions>} */
48+
const tsconfig_cache = new Map();
49+
4050
for (const file of files) {
41-
await process_file(input, temp, file, options.config.preprocess, alias, tsconfig, analyse_code);
51+
await process_file(
52+
input,
53+
temp,
54+
file,
55+
options.config.preprocess,
56+
alias,
57+
tsconfig,
58+
analyse_code,
59+
tsconfig_cache
60+
);
4261
}
4362

4463
if (!options.preserve_output) {
@@ -80,6 +99,9 @@ export async function watch(options) {
8099
/** @type {NodeJS.Timeout} */
81100
let timeout;
82101

102+
/** @type {Map<string, import('typescript').CompilerOptions>} */
103+
const tsconfig_cache = new Map();
104+
83105
const watcher = chokidar.watch(input, { ignoreInitial: true });
84106
/** @type {Promise<void>} */
85107
const ready = new Promise((resolve) => watcher.on('ready', resolve));
@@ -89,6 +111,14 @@ export async function watch(options) {
89111

90112
pending.push({ file, type });
91113

114+
if (
115+
file.name.endsWith('tsconfig.json') ||
116+
file.name.endsWith('jsconfig.json') ||
117+
(options.tsconfig && posixify(filepath) === posixify(options.tsconfig))
118+
) {
119+
tsconfig_cache.clear();
120+
}
121+
92122
clearTimeout(timeout);
93123
timeout = setTimeout(async () => {
94124
const files = scan(input, extensions);
@@ -130,7 +160,8 @@ export async function watch(options) {
130160
options.config.preprocess,
131161
alias,
132162
tsconfig,
133-
analyse_code
163+
analyse_code,
164+
tsconfig_cache
134165
);
135166
} catch (e) {
136167
errored = true;
@@ -209,8 +240,18 @@ function normalize_options(options) {
209240
* @param {Record<string, string>} aliases
210241
* @param {string | undefined} tsconfig
211242
* @param {(name: string, code: string) => void} analyse_code
243+
* @param {Map<string, import('typescript').CompilerOptions>} tsconfig_cache
212244
*/
213-
async function process_file(input, output, file, preprocessor, aliases, tsconfig, analyse_code) {
245+
async function process_file(
246+
input,
247+
output,
248+
file,
249+
preprocessor,
250+
aliases,
251+
tsconfig,
252+
analyse_code,
253+
tsconfig_cache
254+
) {
214255
const filename = path.join(input, file.name);
215256
const dest = path.join(output, file.dest);
216257

@@ -229,7 +270,13 @@ async function process_file(input, output, file, preprocessor, aliases, tsconfig
229270
contents = resolve_aliases(input, file.name, contents, aliases);
230271

231272
if (file.name.endsWith('.ts') && !file.name.endsWith('.d.ts')) {
232-
contents = await transpile_ts(tsconfig, filename, contents);
273+
contents = await transpile_ts(tsconfig, filename, contents, tsconfig_cache);
274+
} else if (file.is_svelte) {
275+
const options = await load_tsconfig(tsconfig, filename, tsconfig_cache);
276+
// Mimic TypeScript's transpileModule behavior for Svelte files
277+
if (options.rewriteRelativeImportExtensions) {
278+
contents = resolve_ts_endings(contents);
279+
}
233280
}
234281

235282
analyse_code(file.name, contents);

packages/package/src/typescript.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,11 @@ export async function emit_dts(input, output, final_output, cwd, alias, files, t
9494
* @param {string | undefined} tsconfig
9595
* @param {string} filename
9696
* @param {string} source
97+
* @param {Map<string, import('typescript').CompilerOptions>} cache
9798
*/
98-
export async function transpile_ts(tsconfig, filename, source) {
99+
export async function transpile_ts(tsconfig, filename, source, cache) {
99100
const ts = await try_load_ts();
100-
const options = load_tsconfig(tsconfig, filename, ts);
101+
const options = await load_tsconfig(tsconfig, filename, cache, ts);
101102
// transpileModule treats NodeNext as CommonJS because it doesn't read the package.json. Therefore we need to override it.
102103
// Also see https://github.com/microsoft/TypeScript/issues/53022 (the filename workaround doesn't work).
103104
return ts.transpileModule(source, {
@@ -123,14 +124,29 @@ async function try_load_ts() {
123124
/**
124125
* @param {string | undefined} tsconfig
125126
* @param {string} filename
126-
* @param {import('typescript')} ts
127+
* @param {Map<string, import('typescript').CompilerOptions>} cache
128+
* @param {import('typescript')} [ts]
127129
*/
128-
function load_tsconfig(tsconfig, filename, ts) {
130+
export async function load_tsconfig(tsconfig, filename, cache, ts) {
131+
if (!ts) {
132+
ts = await try_load_ts();
133+
}
134+
129135
let config_filename;
136+
/** @type {string[]} */
137+
const traversed_dirs = [];
130138

131139
if (tsconfig) {
132140
if (fs.existsSync(tsconfig)) {
133141
config_filename = tsconfig;
142+
143+
const cached = cache.get(config_filename);
144+
if (cached) {
145+
return cached;
146+
} else {
147+
// This isn't really a dir, but it simplifies the caching logic
148+
traversed_dirs.push(config_filename);
149+
}
134150
} else {
135151
throw new Error('Failed to locate provided tsconfig or jsconfig');
136152
}
@@ -140,6 +156,16 @@ function load_tsconfig(tsconfig, filename, ts) {
140156
// so we implement it ourselves
141157
let dir = filename;
142158
while (dir !== (dir = path.dirname(dir))) {
159+
const cached = cache.get(dir);
160+
if (cached) {
161+
for (const traversed of traversed_dirs) {
162+
cache.set(traversed, cached);
163+
}
164+
return cached;
165+
}
166+
167+
traversed_dirs.push(dir);
168+
143169
const tsconfig = path.join(dir, 'tsconfig.json');
144170
const jsconfig = path.join(dir, 'jsconfig.json');
145171

@@ -175,5 +201,10 @@ function load_tsconfig(tsconfig, filename, ts) {
175201
{ sourceMap: false },
176202
config_filename
177203
);
204+
205+
for (const dir of traversed_dirs) {
206+
cache.set(dir, options);
207+
}
208+
178209
return options;
179210
}

packages/package/src/utils.js

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,7 @@ const is_svelte_5_plus = Number(VERSION.split('.')[0]) >= 5;
1515
* @returns {string}
1616
*/
1717
export function resolve_aliases(input, file, content, aliases) {
18-
/**
19-
* @param {string} match
20-
* @param {string} quote
21-
* @param {string} import_path
22-
*/
23-
const replace_import_path = (match, quote, import_path) => {
18+
return adjust_imports(content, (import_path) => {
2419
for (const [alias, value] of Object.entries(aliases)) {
2520
if (
2621
import_path !== alias &&
@@ -33,7 +28,48 @@ export function resolve_aliases(input, file, content, aliases) {
3328
const full_import_path = path.join(value, import_path.slice(alias.length));
3429
let resolved = posixify(path.relative(path.dirname(full_path), full_import_path));
3530
resolved = resolved.startsWith('.') ? resolved : './' + resolved;
36-
return match.replace(quote + import_path + quote, quote + resolved + quote);
31+
return resolved;
32+
}
33+
return import_path;
34+
});
35+
}
36+
37+
/**
38+
* Replace .ts extensions with .js in relative import/export statements
39+
*
40+
* @param {string} content
41+
* @returns {string}
42+
*/
43+
export function resolve_ts_endings(content) {
44+
return adjust_imports(content, (import_path) => {
45+
if (
46+
import_path[0] === '.' &&
47+
((import_path[1] === '.' && import_path[2] === '/') || import_path[1] === '/') &&
48+
import_path.endsWith('.ts')
49+
) {
50+
return import_path.slice(0, -3) + '.js';
51+
}
52+
return import_path;
53+
});
54+
}
55+
56+
/**
57+
* Adjust import paths
58+
*
59+
* @param {string} content
60+
* @param {(import_path: string) => string} adjust
61+
* @returns {string}
62+
*/
63+
export function adjust_imports(content, adjust) {
64+
/**
65+
* @param {string} match
66+
* @param {string} quote
67+
* @param {string} import_path
68+
*/
69+
const replace_import_path = (match, quote, import_path) => {
70+
const adjusted = adjust(import_path);
71+
if (adjusted !== import_path) {
72+
return match.replace(quote + import_path + quote, quote + adjusted + quote);
3773
}
3874
return match;
3975
};

packages/package/test/fixtures/typescript-alias-rewrites/expected/index.d.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

packages/package/test/fixtures/typescript-alias-rewrites/expected/index.js

Lines changed: 0 additions & 2 deletions
This file was deleted.

packages/package/test/fixtures/typescript-alias-rewrites/src/lib/index.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

packages/package/test/fixtures/typescript-alias-rewrites/svelte.config.js

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script lang="ts">import { helper } from './helper.js';
2+
import { helper2 } from './helper2.js';
3+
</script>
4+
5+
{helper()}{helper2()}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
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> {
2+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3+
$$bindings?: Bindings;
4+
} & Exports;
5+
(internal: unknown, props: {
6+
$$events?: Events;
7+
$$slots?: Slots;
8+
}): Exports & {
9+
$set?: any;
10+
$on?: any;
11+
};
12+
z_$$bindings?: Bindings;
13+
}
14+
declare const Demo: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15+
[evt: string]: CustomEvent<any>;
16+
}, {}, {}, string>;
17+
type Demo = InstanceType<typeof Demo>;
18+
export default Demo;

0 commit comments

Comments
 (0)