--- /dev/null
+---
+'@sveltejs/package': patch
+---
+
+fix: transform `.ts` extensions to `.js` in import/export statements of Svelte files when using `rewriteRelativeImportExtensions`
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';
/**
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) {
/** @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));
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);
options.config.preprocess,
alias,
tsconfig,
- analyse_code
+ analyse_code,
+ tsconfig_cache
);
} catch (e) {
errored = true;
* @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);
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);
* @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, {
/**
* @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');
}
// 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');
{ sourceMap: false },
config_filename
);
+
+ for (const dir of traversed_dirs) {
+ cache.set(dir, options);
+ }
+
return options;
}
* @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 &&
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;
};
+++ /dev/null
-import { helper } from "./helper.ts";
-export { helper };
+++ /dev/null
-import { helper } from "./helper.js";
-export { helper };
+++ /dev/null
-import { helper } from '$lib/helper.ts';
-export { helper };
+++ /dev/null
-import preprocess from 'svelte-preprocess';
-
-export default {
- preprocess: preprocess()
-};
--- /dev/null
+<script lang="ts">import { helper } from './helper.js';
+ import { helper2 } from './helper2.js';
+</script>
+
+{helper()}{helper2()}
--- /dev/null
+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;
--- /dev/null
+export declare const helper2: () => string;
--- /dev/null
+export const helper2 = () => '';
--- /dev/null
+import { helper } from "./helper.ts";
+import { helper2 } from "./helper2.ts";
+export { helper, helper2 };
--- /dev/null
+import { helper } from "./helper.js";
+import { helper2 } from "./helper2.js";
+export { helper, helper2 };
{
- "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"
--- /dev/null
+<script lang="ts">
+ import { helper } from '$lib/helper.ts';
+ import { helper2 } from './helper2.ts';
+</script>
+
+{helper()}{helper2()}
--- /dev/null
+export const helper2 = () => '';
--- /dev/null
+import { helper } from '$lib/helper.ts';
+import { helper2 } from './helper2.ts';
+export { helper, helper2 };
--- /dev/null
+export default {};
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