Skip to content

Commit 196dfd3

Browse files
authored
fix: use typesVersions to wire up deep imports (#9133)
and explain that stuff in the docs closes #9114
1 parent 36b0168 commit 196dfd3

File tree

5 files changed

+119
-9
lines changed

5 files changed

+119
-9
lines changed

.changeset/fluffy-trees-rule.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte-migrate': patch
3+
---
4+
5+
fix: update existing exports with prepended outdir

.changeset/moody-donkeys-end.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte-migrate': patch
3+
---
4+
5+
fix: use typesVersions to wire up deep imports

documentation/docs/30-advanced/70-packaging.md

+32
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ declare module 'your-library/Foo.svelte';
110110
import Foo from 'your-library/Foo.svelte';
111111
```
112112

113+
> Beware that doing this will need additional care if you provide type definitions. Read more about the caveat [here](#typescript)
114+
113115
In general, each key of the exports map is the path the user will have to use to import something from your package, and the value is the path to the file that will be imported or a map of export conditions which in turn contains these file paths.
114116

115117
Read more about `exports` [here](https://nodejs.org/docs/latest-v18.x/api/packages.html#package-entry-points).
@@ -124,6 +126,36 @@ This is a legacy field that enabled tooling to recognise Svelte component librar
124126
}
125127
```
126128

129+
## TypeScript
130+
131+
You should ship type definitions for your library even if you don't use TypeScript yourself so that people who do get proper intellisense when using your library. `@sveltejs/package` makes the process of generating types mostly opaque to you. By default, when packaging your library, type definitions are auto-generated for JavaScript, TypeScript and Svelte files. All you need to ensure is that the `types` condition in the [exports](#anatomy-of-a-package-json-exports) map points to the correct files. When initialising a library project through `npm create svelte@latest`, this is automatically setup for the root export.
132+
133+
If you have something else than a root export however — for example providing a `your-library/foo` import — you need to take additional care for providing type definitions. Unfortunately, TypeScript by default will _not_ resolve the `types` condition for an export like `{ "./foo": { "types": "./dist/foo.d.ts", ... }}`. Instead, it will search for a `foo.d.ts` relative to the root of your library (i.e. `your-library/foo.d.ts` instead of `your-library/dist/foo.d.ts`). To fix this, you have two options:
134+
135+
The first option is to require people using your library to set the `moduleResolution` option in their `tsconfig/jsconfig.json` to `bundler` (available since TypeScript 5, the best and recommended option in the future), `node16` or `nodenext`. This opts TypeScript into actually looking at the exports map and resolving the types correctly.
136+
137+
The second option is to (ab)use the `typesVersions` feature from TypeScript to wire up the types. This is a field inside `package.json` TypeScript uses to check for different type definitions depending on the TypeScript version, and also contains a path mapping feature for that. We leverage that path mapping feature to get what we want. For the mentioned `foo` export above, the corresponding `typesVersions` looks like this:
138+
139+
```json
140+
{
141+
"exports": {
142+
"./foo": {
143+
"types": "./dist/foo.d.ts",
144+
"svelte": "./dist/foo.js"
145+
}
146+
},
147+
"typesVersions": {
148+
">4.0": {
149+
"foo": ["./dist/foo.d.ts"]
150+
}
151+
}
152+
}
153+
```
154+
155+
`>4.0` tells TypeScript to check the inner map if the used TypeScript version is greater than 4 (which should in practice always be true). The inner map tells TypeScript that the typings for `your-library/foo` are found within `./dist/foo.d.ts`, which essentially replicates the `exports` condition. You also have `*` as a wildcard at your disposal to make many type definitions at once available without repeating yourself.
156+
157+
You can read more about that feature [here](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions).
158+
127159
## Best practices
128160

129161
You should avoid using [SvelteKit-specific modules](modules) like `$app` in your packages unless you intend for them to only be consumable by other SvelteKit projects. E.g. rather than using `import { browser } from '$app/environment'` you could use `import { BROWSER } from 'esm-env'` ([see esm-env docs](https://github.com/benmccann/esm-env)). You may also wish to pass in things like the current URL or a navigation action as a prop rather than relying directly on `$app/stores`, `$app/navigation`, etc. Writing your app in this more generic fashion will also make it easier to setup tools for testing, UI demos and so on.

packages/migrate/migrations/package/migrate_pkg.js

+53-6
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export function update_pkg_json(config, pkg, files) {
9292

9393
/** @type {Record<string, string>} */
9494
const clashes = {};
95+
/** @type {Record<string, [string]>} */
96+
const types_versions = {};
9597

9698
for (const file of files) {
9799
if (file.is_included && file.is_exported) {
@@ -106,23 +108,48 @@ export function update_pkg_json(config, pkg, files) {
106108
);
107109
}
108110

111+
const has_type = config.package.emitTypes && (file.is_svelte || file.dest.endsWith('.js'));
112+
const out_dir_type_path = `./${out_dir}/${
113+
file.is_svelte ? `${file.dest}.d.ts` : file.dest.slice(0, -'.js'.length) + '.d.ts'
114+
}`;
115+
116+
if (has_type && key.slice(2) /* don't add root index type */) {
117+
if (!pkg.exports[key]) {
118+
types_versions[key.slice(2)] = [out_dir_type_path];
119+
} else {
120+
const path_without_ext = pkg.exports[key].slice(
121+
0,
122+
-path.extname(pkg.exports[key]).length
123+
);
124+
types_versions[key.slice(2)] = [
125+
`./${out_dir}/${(pkg.exports[key].types ?? path_without_ext + '.d.ts').slice(2)}`
126+
];
127+
}
128+
}
129+
109130
if (!pkg.exports[key]) {
110-
const has_type = config.package.emitTypes && (file.is_svelte || file.dest.endsWith('.js'));
111131
const needs_svelte_condition = file.is_svelte || path.basename(file.dest) === 'index.js';
112132
// JSON.stringify will remove the undefined entries
113133
pkg.exports[key] = {
114-
types: has_type
115-
? `./${out_dir}/${
116-
file.is_svelte ? `${file.dest}.d.ts` : file.dest.slice(0, -'.js'.length) + '.d.ts'
117-
}`
118-
: undefined,
134+
types: has_type ? out_dir_type_path : undefined,
119135
svelte: needs_svelte_condition ? `./${out_dir}/${file.dest}` : undefined,
120136
default: `./${out_dir}/${file.dest}`
121137
};
122138

123139
if (Object.values(pkg.exports[key]).filter(Boolean).length === 1) {
124140
pkg.exports[key] = pkg.exports[key].default;
125141
}
142+
} else {
143+
// Rewrite existing export to point to the new output directory
144+
if (typeof pkg.exports[key] === 'string') {
145+
pkg.exports[key] = prepend_out_dir(pkg.exports[key], out_dir);
146+
} else {
147+
for (const condition in pkg.exports[key]) {
148+
if (typeof pkg.exports[key][condition] === 'string') {
149+
pkg.exports[key][condition] = prepend_out_dir(pkg.exports[key][condition], out_dir);
150+
}
151+
}
152+
}
126153
}
127154

128155
clashes[key] = original;
@@ -154,7 +181,27 @@ export function update_pkg_json(config, pkg, files) {
154181
)
155182
);
156183
}
184+
} else if (pkg.svelte) {
185+
// Rewrite existing "svelte" field to point to the new output directory
186+
pkg.svelte = prepend_out_dir(pkg.svelte, out_dir);
187+
}
188+
189+
// https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions
190+
// A hack to get around the limitation that TS doesn't support "exports" field with moduleResolution: 'node'
191+
if (Object.keys(types_versions).length > 0) {
192+
pkg.typesVersions = { '>4.0': types_versions };
157193
}
158194

159195
return pkg;
160196
}
197+
198+
/**
199+
* Rewrite existing path to point to the new output directory
200+
* @param {string} path
201+
* @param {string} out_dir
202+
*/
203+
function prepend_out_dir(path, out_dir) {
204+
if (!path.startsWith(`./${out_dir}`) && path.startsWith('./')) {
205+
return `./${out_dir}/${path.slice(2)}`;
206+
}
207+
}

packages/migrate/migrations/package/migrate_pkg.spec.js

+24-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ test('Updates package.json', () => {
1313
},
1414
exports: {
1515
'./ignored': './something.js'
16-
}
16+
},
17+
svelte: './index.js'
1718
},
1819
[
1920
{
@@ -37,6 +38,13 @@ test('Updates package.json', () => {
3738
is_included: true,
3839
is_svelte: false
3940
},
41+
{
42+
name: 'bar/index.js',
43+
dest: 'bar/index.js',
44+
is_exported: true,
45+
is_included: true,
46+
is_svelte: false
47+
},
4048
{
4149
name: 'index.js',
4250
dest: 'index.js',
@@ -77,9 +85,22 @@ test('Updates package.json', () => {
7785
types: './package/baz.d.ts',
7886
default: './package/baz.js'
7987
},
80-
'./ignored': './something.js'
88+
'./bar': {
89+
types: './package/bar/index.d.ts',
90+
svelte: './package/bar/index.js',
91+
default: './package/bar/index.js'
92+
},
93+
'./ignored': './package/something.js'
8194
},
82-
svelte: './package/index.js'
95+
svelte: './package/index.js',
96+
typesVersions: {
97+
'>4.0': {
98+
'foo/Bar.svelte': ['./package/foo/Bar.svelte.d.ts'],
99+
baz: ['./package/baz.d.ts'],
100+
bar: ['./package/bar/index.d.ts'],
101+
ignored: ['./package/something.d.ts']
102+
}
103+
}
83104
});
84105
});
85106

0 commit comments

Comments
 (0)