Repository: sveltejs/language-tools
Pull request #1085 (2021-07-04 10:52:35)

(feat) emitDts functionality for svelte2tsx
New method which makes it possible to emit dts type definitions, including Svelte files


Patch:
diff --git a/.prettierignore b/.prettierignore
index 97dae252..3caa6aac 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,6 +2,7 @@ packages/svelte2tsx/*.d.ts
 packages/svelte2tsx/test/*/samples/*/*sx
 packages/svelte2tsx/test/*/samples/*/*.svelte
 packages/svelte2tsx/test/sourcemaps/samples/*
+packages/svelte2tsx/test/emitDts/samples/*/expected/**
 packages/language-server/test/**/*.svelte
 packages/language-server/test/**/testfiles/**/*.ts
 packages/svelte-vscode/syntaxes/*.yaml
diff --git a/packages/svelte2tsx/index.d.ts b/packages/svelte2tsx/index.d.ts
index 629ee4c7..74b6d4fa 100644
--- a/packages/svelte2tsx/index.d.ts
+++ b/packages/svelte2tsx/index.d.ts
@@ -47,3 +47,30 @@ export function svelte2tsx(
         mode?: 'tsx' | 'dts'
     }
 ): SvelteCompiledToTsx
+
+export interface EmitDtsConig {
+    /**
+     * Where to output the declaration files
+     */
+    declarationDir: string;
+    /**
+     * Path to `svelte-shims.d.ts` of `svelte2tsx`.
+     * Example: `require.resolve('svelte2tsx/svelte-shims.d.ts')`
+     */
+    svelteShimsPath: string;
+    /**
+     * If you want to emit types only for part of your project,
+     * then set this to the folder for which the types should be emitted.
+     * Most of the time you don't need this. For SvelteKit, this is for example
+     * set to `src/lib` by default.
+     */
+    libRoot?: string;
+}
+
+/**
+ * Searches for a jsconfig or tsconfig starting at `root` and emits d.ts files
+ * into `declarationDir` using the ambient file from `svelteShimsPath`.
+ * Note: Handwritten `d.ts` files are not copied over; TypeScript does not
+ * touch these files.
+ */
+export function emitDts(config: EmitDtsConig): Promise<void>;
diff --git a/packages/svelte2tsx/src/emitDts.ts b/packages/svelte2tsx/src/emitDts.ts
new file mode 100644
index 00000000..81817918
--- /dev/null
+++ b/packages/svelte2tsx/src/emitDts.ts
@@ -0,0 +1,225 @@
+import * as path from 'path';
+import ts from 'typescript';
+import { svelte2tsx } from './svelte2tsx';
+
+export interface EmitDtsConig {
+    declarationDir: string;
+    svelteShimsPath: string;
+    libRoot?: string;
+}
+
+export async function emitDts(config: EmitDtsConig) {
+    const svelteMap = await createSvelteMap(config);
+    const { options, filenames } = loadTsconfig(config, svelteMap);
+    const host = await createTsCompilerHost(options, svelteMap);
+    const program = ts.createProgram(filenames, options, host);
+    program.emit();
+}
+
+function loadTsconfig(config: EmitDtsConig, svelteMap: SvelteMap) {
+    const libRoot = config.libRoot || process.cwd();
+
+    const jsconfigFile = ts.findConfigFile(libRoot, ts.sys.fileExists, 'jsconfig.json');
+    let tsconfigFile = ts.findConfigFile(libRoot, ts.sys.fileExists);
+
+    if (!tsconfigFile && !jsconfigFile) {
+        throw new Error('Failed to locate tsconfig or jsconfig');
+    }
+
+    tsconfigFile = tsconfigFile || jsconfigFile;
+    if (jsconfigFile && isSubpath(path.dirname(tsconfigFile), path.dirname(jsconfigFile))) {
+        tsconfigFile = jsconfigFile;
+    }
+
+    tsconfigFile = path.isAbsolute(tsconfigFile) ? tsconfigFile : path.join(libRoot, tsconfigFile);
+    const basepath = path.dirname(tsconfigFile);
+    const { error, config: tsConfig } = ts.readConfigFile(tsconfigFile, ts.sys.readFile);
+
+    if (error) {
+        throw new Error('Malformed tsconfig\n' + JSON.stringify(error, null, 2));
+    }
+
+    // Rewire includes and files. This ensures that only the files inside the lib are traversed and
+    // that the outputted types have the correct directory depth.
+    // This is a little brittle because we then may include more than the user wants
+    const libPathRelative = path.relative(basepath, libRoot).split(path.sep).join('/');
+    if (libPathRelative) {
+        tsConfig.include = [`${libPathRelative}/**/*`];
+        tsConfig.files = [];
+    }
+
+    const { options, fileNames } = ts.parseJsonConfigFileContent(
+        tsConfig,
+        ts.sys,
+        basepath,
+        { sourceMap: false },
+        tsconfigFile,
+        undefined,
+        [{ extension: 'svelte', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }]
+    );
+
+    const filenames = fileNames.map((name) => {
+        if (!isSvelteFilepath(name)) {
+            return name;
+        }
+        // We need to trick TypeScript into thinking that Svelte files
+        // are either TS or JS files in order to generate correct d.ts
+        // definition files.
+        const isTsFile = svelteMap.add(name);
+        return name + (isTsFile ? '.ts' : '.js');
+    });
+
+    // Add ambient functions so TS knows how to resolve its invocations in the
+    // code output of svelte2tsx.
+    filenames.push(config.svelteShimsPath);
+
+    return {
+        options: {
+            ...options,
+            noEmit: false, // Set to true in case of jsconfig, force false, else nothing is emitted
+            moduleResolution: ts.ModuleResolutionKind.NodeJs, // Classic if not set, which gives wrong results
+            declaration: true, // Needed for d.ts file generation
+            emitDeclarationOnly: true, // We only want d.ts file generation
+            declarationDir: config.declarationDir, // Where to put the declarations
+            allowNonTsExtensions: true
+        },
+        filenames
+    };
+}
+
+async function createTsCompilerHost(options: any, svelteMap: SvelteMap) {
+    const host = ts.createCompilerHost(options);
+    // TypeScript writes the files relative to the found tsconfig/jsconfig
+    // which - at least in the case of the tests - is wrong. Therefore prefix
+    // the output paths. See Typescript issue #25430 for more.
+    const pathPrefix = path.relative(process.cwd(), path.dirname(options.configFilePath));
+
+    const svelteSys: ts.System = {
+        ...ts.sys,
+        fileExists(originalPath) {
+            const path = ensureRealSvelteFilepath(originalPath);
+            const exists = ts.sys.fileExists(path);
+            if (exists && isSvelteFilepath(path)) {
+                const isTsFile = svelteMap.add(path);
+                if (
+                    (isTsFile && !isTsFilepath(originalPath)) ||
+                    (!isTsFile && isTsFilepath(originalPath))
+                ) {
+                    return false;
+                }
+            }
+            return exists;
+        },
+        readFile(path, encoding = 'utf-8') {
+            if (isVirtualSvelteFilepath(path) || isSvelteFilepath(path)) {
+                path = ensureRealSvelteFilepath(path);
+                return svelteMap.get(path);
+            } else {
+                return ts.sys.readFile(path, encoding);
+            }
+        },
+        readDirectory(path, extensions, exclude, include, depth) {
+            const extensionsWithSvelte = (extensions || []).concat('.svelte');
+            return ts.sys.readDirectory(path, extensionsWithSvelte, exclude, include, depth);
+        },
+        writeFile(fileName, data, writeByteOrderMark) {
+            return ts.sys.writeFile(
+                pathPrefix ? path.join(pathPrefix, fileName) : fileName,
+                data,
+                writeByteOrderMark
+            );
+        }
+    };
+
+    host.fileExists = svelteSys.fileExists;
+    host.readFile = svelteSys.readFile;
+    host.readDirectory = svelteSys.readDirectory;
+    host.writeFile = svelteSys.writeFile;
+
+    host.resolveModuleNames = (
+        moduleNames,
+        containingFile,
+        _reusedNames,
+        _redirectedReference,
+        compilerOptions
+    ) => {
+        return moduleNames.map((moduleName) => {
+            return resolveModuleName(moduleName, containingFile, compilerOptions);
+        });
+    };
+
+    function resolveModuleName(name: string, containingFile: string, compilerOptions: any) {
+        // Delegate to the TS resolver first.
+        // If that does not bring up anything, try the Svelte Module loader
+        // which is able to deal with .svelte files.
+        const tsResolvedModule = ts.resolveModuleName(
+            name,
+            containingFile,
+            compilerOptions,
+            ts.sys
+        ).resolvedModule;
+        if (tsResolvedModule && !isVirtualSvelteFilepath(tsResolvedModule.resolvedFileName)) {
+            return tsResolvedModule;
+        }
+
+        return ts.resolveModuleName(name, containingFile, compilerOptions, svelteSys)
+            .resolvedModule;
+    }
+
+    return host;
+}
+
+interface SvelteMap {
+    add: (path: string) => boolean;
+    get: (key: string) => string | undefined;
+}
+
+/**
+ * Generates a map to which we add the transformed code of Svelte files
+ * early on when we first need to look at the file contents and can read
+ * those transformed source later on.
+ */
+async function createSvelteMap(config): Promise<SvelteMap> {
+    const svelteFiles = new Map();
+
+    function add(path: string): boolean {
+        const code = ts.sys.readFile(path, 'utf-8');
+        const isTsFile = // svelte-preprocess allows default languages
+            ['ts', 'typescript'].includes(config.preprocess?.defaultLanguages?.script) ||
+            /<script\s+[^>]*?lang=('|")(ts|typescript)('|")/.test(code);
+        const transformed = svelte2tsx(code, {
+            filename: path,
+            isTsFile,
+            mode: 'dts'
+        }).code;
+        svelteFiles.set(path, transformed);
+        return isTsFile;
+    }
+
+    return { add, get: (key: string) => svelteFiles.get(key) };
+}
+
+function isSvelteFilepath(filePath: string) {
+    return filePath.endsWith('.svelte');
+}
+
+function isTsFilepath(filePath: string) {
+    return filePath.endsWith('.ts');
+}
+
+function isVirtualSvelteFilepath(filePath: string) {
+    return filePath.endsWith('.svelte.ts') || filePath.endsWith('svelte.js');
+}
+
+function toRealSvelteFilepath(filePath: string) {
+    return filePath.slice(0, -3); // -'.js'.length || -'.ts'.length
+}
+
+function ensureRealSvelteFilepath(filePath: string) {
+    return isVirtualSvelteFilepath(filePath) ? toRealSvelteFilepath(filePath) : filePath;
+}
+
+function isSubpath(maybeParent: string, maybeChild: string) {
+    const relative = path.relative(maybeParent, maybeChild);
+    return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
+}
diff --git a/packages/svelte2tsx/src/index.ts b/packages/svelte2tsx/src/index.ts
index 1c32f786..ef3d5bcf 100644
--- a/packages/svelte2tsx/src/index.ts
+++ b/packages/svelte2tsx/src/index.ts
@@ -1,1 +1,2 @@
 export { svelte2tsx } from './svelte2tsx';
+export { emitDts } from './emitDts';


Test patch:
diff --git a/packages/svelte2tsx/test/emitDts/index.ts b/packages/svelte2tsx/test/emitDts/index.ts
new file mode 100644
index 00000000..5469c88c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/index.ts
@@ -0,0 +1,60 @@
+import * as assert from 'assert';
+import * as fs from 'fs';
+import { join } from 'path';
+import { emitDts } from '../../src';
+
+function rimraf(path: string) {
+    ((fs as any).rmSync || fs.rmdirSync)(path, { recursive: true, force: true });
+}
+
+async function testEmitDts(sample: string) {
+    const cwd = join(__dirname, 'samples', sample);
+
+    try {
+        const config = fs.existsSync(join(cwd, 'config.json'))
+            ? JSON.parse(fs.readFileSync(join(cwd, 'config.json'), 'utf-8'))
+            : {};
+        await emitDts({
+            declarationDir: 'package',
+            svelteShimsPath: require.resolve(join(process.cwd(), 'svelte-shims.d.ts')),
+            ...config,
+            libRoot: config.libRoot ? join(cwd, config.libRoot) : cwd
+        });
+        const expectedFiles = fs.readdirSync(join(cwd, 'expected'));
+        const actual_files = fs.readdirSync(join(cwd, 'package'));
+        assert.strictEqual(
+            actual_files.length,
+            expectedFiles.length,
+            'Contains a different number of files. Expected ' +
+                expectedFiles.join(',') +
+                ' , got ' +
+                actual_files.join(',')
+        );
+
+        for (const file of actual_files) {
+            assert.strictEqual(
+                expectedFiles.includes(file),
+                true,
+                `Did not expect file or folder ${file}`
+            );
+            const expectedContent = fs.readFileSync(join(cwd, 'expected', file), 'utf-8');
+            const actualContent = fs.readFileSync(join(cwd, 'package', file), 'utf-8');
+            assert.strictEqual(
+                actualContent,
+                expectedContent,
+                `Expected equal file contents for ${file}`
+            );
+        }
+    } finally {
+        rimraf(join(cwd, 'package'));
+    }
+}
+
+describe('emitDts', async () => {
+    const samples = fs.readdirSync(join(__dirname, 'samples'));
+    let samplesToTest = samples.filter((s) => s.endsWith('.solo'));
+    samplesToTest = samplesToTest.length ? samplesToTest : samples;
+    for (const sample of samplesToTest) {
+        it(sample, async () => await testEmitDts(sample)).timeout(5000);
+    }
+});
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/config.json b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/config.json
new file mode 100644
index 00000000..dd4044b7
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/config.json
@@ -0,0 +1,3 @@
+{
+    "libRoot": "src/lib"
+}
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/Test.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/Test.svelte.d.ts
new file mode 100644
index 00000000..3e176b43
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/Test.svelte.d.ts
@@ -0,0 +1,36 @@
+/** @typedef {typeof __propDef.props}  TestProps */
+/** @typedef {typeof __propDef.events}  TestEvents */
+/** @typedef {typeof __propDef.slots}  TestSlots */
+export default class Test extends SvelteComponentTyped<{
+    astring: string;
+}, {
+    event: CustomEvent<any>;
+} & {
+    [evt: string]: CustomEvent<any>;
+}, {
+    default: {
+        astring: string;
+    };
+}> {
+    get astring(): string;
+}
+export type TestProps = typeof __propDef.props;
+export type TestEvents = typeof __propDef.events;
+export type TestSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from "svelte";
+declare const __propDef: {
+    props: {
+        astring: string;
+    };
+    events: {
+        event: CustomEvent<any>;
+    } & {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {
+        default: {
+            astring: string;
+        };
+    };
+};
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/index.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/index.d.ts
new file mode 100644
index 00000000..637e4820
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/expected/index.d.ts
@@ -0,0 +1,1 @@
+export { default as Test } from "./Test.svelte";
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/jsconfig.json b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/jsconfig.json
new file mode 100644
index 00000000..edbf1266
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/jsconfig.json
@@ -0,0 +1,6 @@
+{
+    "compilerOptions": {
+        "checkJs": true
+    },
+    "include": ["./src/**/*.d.ts", "./src/**/*.js", "./src/**/*.ts", "./src/**/*.svelte"]
+}
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/Test2.svelte b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/Test2.svelte
new file mode 100644
index 00000000..06a5a340
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/Test2.svelte
@@ -0,0 +1,6 @@
+<script>
+    /**
+     * @type {import('./foo').Foo}
+     */
+    export let foo;
+</script>
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/Test.svelte b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/Test.svelte
new file mode 100644
index 00000000..31d11839
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/Test.svelte
@@ -0,0 +1,12 @@
+<script>
+    import { createEventDispatcher } from 'svelte';
+    /**
+     * @type {string}
+     */
+    export const astring;
+
+    const dispatch = createEventDispatcher();
+    dispatch('event', true);
+</script>
+
+<slot {astring} />
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/index.js b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/index.js
new file mode 100644
index 00000000..4c44188c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript-libRoot/src/lib/index.js
@@ -0,0 +1,1 @@
+export { default as Test } from './Test.svelte';
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test.svelte.d.ts
new file mode 100644
index 00000000..3e176b43
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test.svelte.d.ts
@@ -0,0 +1,36 @@
+/** @typedef {typeof __propDef.props}  TestProps */
+/** @typedef {typeof __propDef.events}  TestEvents */
+/** @typedef {typeof __propDef.slots}  TestSlots */
+export default class Test extends SvelteComponentTyped<{
+    astring: string;
+}, {
+    event: CustomEvent<any>;
+} & {
+    [evt: string]: CustomEvent<any>;
+}, {
+    default: {
+        astring: string;
+    };
+}> {
+    get astring(): string;
+}
+export type TestProps = typeof __propDef.props;
+export type TestEvents = typeof __propDef.events;
+export type TestSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from "svelte";
+declare const __propDef: {
+    props: {
+        astring: string;
+    };
+    events: {
+        event: CustomEvent<any>;
+    } & {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {
+        default: {
+            astring: string;
+        };
+    };
+};
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test2.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test2.svelte.d.ts
new file mode 100644
index 00000000..d025bccb
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/Test2.svelte.d.ts
@@ -0,0 +1,23 @@
+/** @typedef {typeof __propDef.props}  Test2Props */
+/** @typedef {typeof __propDef.events}  Test2Events */
+/** @typedef {typeof __propDef.slots}  Test2Slots */
+export default class Test2 extends SvelteComponentTyped<{
+    foo: boolean;
+}, {
+    [evt: string]: CustomEvent<any>;
+}, {}> {
+}
+export type Test2Props = typeof __propDef.props;
+export type Test2Events = typeof __propDef.events;
+export type Test2Slots = typeof __propDef.slots;
+import { SvelteComponentTyped } from "svelte";
+declare const __propDef: {
+    props: {
+        foo: import('./foo').Foo;
+    };
+    events: {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {};
+};
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/expected/TestNoTypes.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/TestNoTypes.svelte.d.ts
new file mode 100644
index 00000000..4b00e8db
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/TestNoTypes.svelte.d.ts
@@ -0,0 +1,37 @@
+/** @typedef {typeof __propDef.props}  TestNoTypesProps */
+/** @typedef {typeof __propDef.events}  TestNoTypesEvents */
+/** @typedef {typeof __propDef.slots}  TestNoTypesSlots */
+export default class TestNoTypes extends SvelteComponentTyped<{
+    noType: any;
+    initializer?: string;
+}, {
+    event: CustomEvent<any>;
+} & {
+    [evt: string]: CustomEvent<any>;
+}, {
+    default: {
+        noType: any;
+    };
+}> {
+}
+export type TestNoTypesProps = typeof __propDef.props;
+export type TestNoTypesEvents = typeof __propDef.events;
+export type TestNoTypesSlots = typeof __propDef.slots;
+import { SvelteComponentTyped } from "svelte";
+declare const __propDef: {
+    props: {
+        noType: any;
+        initializer?: string;
+    };
+    events: {
+        event: CustomEvent<any>;
+    } & {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {
+        default: {
+            noType: any;
+        };
+    };
+};
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/expected/index.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/index.d.ts
new file mode 100644
index 00000000..637e4820
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/expected/index.d.ts
@@ -0,0 +1,1 @@
+export { default as Test } from "./Test.svelte";
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/jsconfig.json b/packages/svelte2tsx/test/emitDts/samples/javascript/jsconfig.json
new file mode 100644
index 00000000..edbf1266
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/jsconfig.json
@@ -0,0 +1,6 @@
+{
+    "compilerOptions": {
+        "checkJs": true
+    },
+    "include": ["./src/**/*.d.ts", "./src/**/*.js", "./src/**/*.ts", "./src/**/*.svelte"]
+}
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test.svelte b/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test.svelte
new file mode 100644
index 00000000..31d11839
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test.svelte
@@ -0,0 +1,12 @@
+<script>
+    import { createEventDispatcher } from 'svelte';
+    /**
+     * @type {string}
+     */
+    export const astring;
+
+    const dispatch = createEventDispatcher();
+    dispatch('event', true);
+</script>
+
+<slot {astring} />
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test2.svelte b/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test2.svelte
new file mode 100644
index 00000000..06a5a340
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/src/Test2.svelte
@@ -0,0 +1,6 @@
+<script>
+    /**
+     * @type {import('./foo').Foo}
+     */
+    export let foo;
+</script>
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/src/TestNoTypes.svelte b/packages/svelte2tsx/test/emitDts/samples/javascript/src/TestNoTypes.svelte
new file mode 100644
index 00000000..479de1d9
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/src/TestNoTypes.svelte
@@ -0,0 +1,9 @@
+<script>
+    import { createEventDispatcher } from 'svelte';
+    export let noType;
+    export let initializer = 'foo';
+    const dispatch = createEventDispatcher();
+    dispatch('event', true);
+</script>
+
+<slot {noType} />
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/src/foo.d.ts b/packages/svelte2tsx/test/emitDts/samples/javascript/src/foo.d.ts
new file mode 100644
index 00000000..c941ad7b
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/src/foo.d.ts
@@ -0,0 +1,1 @@
+export type Foo = boolean;
diff --git a/packages/svelte2tsx/test/emitDts/samples/javascript/src/index.js b/packages/svelte2tsx/test/emitDts/samples/javascript/src/index.js
new file mode 100644
index 00000000..4c44188c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/javascript/src/index.js
@@ -0,0 +1,1 @@
+export { default as Test } from './Test.svelte';
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test.svelte.d.ts
new file mode 100644
index 00000000..897acf8d
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test.svelte.d.ts
@@ -0,0 +1,23 @@
+import { SvelteComponentTyped } from "svelte";
+declare const __propDef: {
+    props: {
+        astring: string;
+    };
+    events: {
+        event: CustomEvent<any>;
+    } & {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {
+        default: {
+            astring: string;
+        };
+    };
+};
+export declare type TestProps = typeof __propDef.props;
+export declare type TestEvents = typeof __propDef.events;
+export declare type TestSlots = typeof __propDef.slots;
+export default class Test extends SvelteComponentTyped<TestProps, TestEvents, TestSlots> {
+    get astring(): string;
+}
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test2.svelte.d.ts b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test2.svelte.d.ts
new file mode 100644
index 00000000..45e91c48
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/Test2.svelte.d.ts
@@ -0,0 +1,17 @@
+import { SvelteComponentTyped } from "svelte";
+import type { Foo } from './foo';
+declare const __propDef: {
+    props: {
+        foo: Foo;
+    };
+    events: {
+        [evt: string]: CustomEvent<any>;
+    };
+    slots: {};
+};
+export declare type Test2Props = typeof __propDef.props;
+export declare type Test2Events = typeof __propDef.events;
+export declare type Test2Slots = typeof __propDef.slots;
+export default class Test2 extends SvelteComponentTyped<Test2Props, Test2Events, Test2Slots> {
+}
+export {};
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/expected/index.d.ts b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/index.d.ts
new file mode 100644
index 00000000..4c44188c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/expected/index.d.ts
@@ -0,0 +1,1 @@
+export { default as Test } from './Test.svelte';
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test.svelte b/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test.svelte
new file mode 100644
index 00000000..b73d6e03
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+    import { createEventDispatcher } from 'svelte';
+    export const astring: string;
+
+    const dispatch = createEventDispatcher();
+    dispatch('event', true);
+</script>
+
+<slot {astring} />
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test2.svelte b/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test2.svelte
new file mode 100644
index 00000000..aef3fb6f
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/src/Test2.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+    import type { Foo } from './foo';
+    export let foo: Foo;
+</script>
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/src/foo.d.ts b/packages/svelte2tsx/test/emitDts/samples/typescript/src/foo.d.ts
new file mode 100644
index 00000000..c941ad7b
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/src/foo.d.ts
@@ -0,0 +1,1 @@
+export type Foo = boolean;
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/src/index.ts b/packages/svelte2tsx/test/emitDts/samples/typescript/src/index.ts
new file mode 100644
index 00000000..4c44188c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/src/index.ts
@@ -0,0 +1,1 @@
+export { default as Test } from './Test.svelte';
diff --git a/packages/svelte2tsx/test/emitDts/samples/typescript/tsconfig.json b/packages/svelte2tsx/test/emitDts/samples/typescript/tsconfig.json
new file mode 100644
index 00000000..5b465a5c
--- /dev/null
+++ b/packages/svelte2tsx/test/emitDts/samples/typescript/tsconfig.json
@@ -0,0 +1,14 @@
+{
+    "compilerOptions": {
+        "moduleResolution": "node",
+        "module": "es2020",
+        "lib": ["es2020", "DOM"],
+        "target": "es2019",
+        "isolatedModules": true,
+        "resolveJsonModule": true,
+        "esModuleInterop": true,
+        "allowJs": true,
+        "checkJs": true
+    },
+    "include": ["./src/**/*.d.ts", "./src/**/*.js", "./src/**/*.ts", "./src/**/*.svelte"]
+}
diff --git a/packages/svelte2tsx/test/test.ts b/packages/svelte2tsx/test/test.ts
index df10cacb..7e951279 100644
--- a/packages/svelte2tsx/test/test.ts
+++ b/packages/svelte2tsx/test/test.ts
@@ -1,3 +1,6 @@
+const { dirname } = require('path');
+const { join } = require('path');
+
 require('ts-node').register({
     project: 'test/tsconfig.json',
     transpileOnly: true
@@ -13,7 +16,9 @@ if (process.env.CI) {
 }
 
 const test_folders = glob('*/index.ts', { cwd: 'test' });
-const solo_folders = test_folders.filter((folder) => /\.solo$/.test(folder));
+const solo_folders = test_folders.filter(
+    (folder) => glob('**/*.solo', { cwd: join('test', dirname(folder)) }).length
+);
 
 if (solo_folders.length) {
     solo_folders.forEach((name) => require('./' + name));