Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use typesVersions to wire up deep imports #9133

Merged
merged 4 commits into from
Feb 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-trees-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-migrate': patch
---

fix: update existing exports with prepended outdir
5 changes: 5 additions & 0 deletions .changeset/moody-donkeys-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-migrate': patch
---

fix: use typesVersions to wire up deep imports
32 changes: 32 additions & 0 deletions documentation/docs/30-advanced/70-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ declare module 'your-library/Foo.svelte';
import Foo from 'your-library/Foo.svelte';
```

> Beware that doing this will need additional care if you provide type definitions. Read more about the caveat [here](#typescript)

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.

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

## TypeScript

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.

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:

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.

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:

```json
{
"exports": {
"./foo": {
"types": "./dist/foo.d.ts",
"svelte": "./dist/foo.js"
}
},
"typesVersions": {
">4.0": {
"foo": ["./dist/foo.d.ts"]
}
}
}
```

`>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.

You can read more about that feature [here](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions).

## Best practices

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.
Expand Down
59 changes: 53 additions & 6 deletions packages/migrate/migrations/package/migrate_pkg.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export function update_pkg_json(config, pkg, files) {

/** @type {Record<string, string>} */
const clashes = {};
/** @type {Record<string, [string]>} */
const types_versions = {};

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

const has_type = config.package.emitTypes && (file.is_svelte || file.dest.endsWith('.js'));
const out_dir_type_path = `./${out_dir}/${
file.is_svelte ? `${file.dest}.d.ts` : file.dest.slice(0, -'.js'.length) + '.d.ts'
}`;

if (has_type && key.slice(2) /* don't add root index type */) {
if (!pkg.exports[key]) {
types_versions[key.slice(2)] = [out_dir_type_path];
} else {
const path_without_ext = pkg.exports[key].slice(
0,
-path.extname(pkg.exports[key]).length
);
types_versions[key.slice(2)] = [
`./${out_dir}/${(pkg.exports[key].types ?? path_without_ext + '.d.ts').slice(2)}`
];
}
}

if (!pkg.exports[key]) {
const has_type = config.package.emitTypes && (file.is_svelte || file.dest.endsWith('.js'));
const needs_svelte_condition = file.is_svelte || path.basename(file.dest) === 'index.js';
// JSON.stringify will remove the undefined entries
pkg.exports[key] = {
types: has_type
? `./${out_dir}/${
file.is_svelte ? `${file.dest}.d.ts` : file.dest.slice(0, -'.js'.length) + '.d.ts'
}`
: undefined,
types: has_type ? out_dir_type_path : undefined,
svelte: needs_svelte_condition ? `./${out_dir}/${file.dest}` : undefined,
default: `./${out_dir}/${file.dest}`
};

if (Object.values(pkg.exports[key]).filter(Boolean).length === 1) {
pkg.exports[key] = pkg.exports[key].default;
}
} else {
// Rewrite existing export to point to the new output directory
if (typeof pkg.exports[key] === 'string') {
pkg.exports[key] = prepend_out_dir(pkg.exports[key], out_dir);
} else {
for (const condition in pkg.exports[key]) {
if (typeof pkg.exports[key][condition] === 'string') {
pkg.exports[key][condition] = prepend_out_dir(pkg.exports[key][condition], out_dir);
}
}
}
}

clashes[key] = original;
Expand Down Expand Up @@ -154,7 +181,27 @@ export function update_pkg_json(config, pkg, files) {
)
);
}
} else if (pkg.svelte) {
// Rewrite existing "svelte" field to point to the new output directory
pkg.svelte = prepend_out_dir(pkg.svelte, out_dir);
}

// https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions
// A hack to get around the limitation that TS doesn't support "exports" field with moduleResolution: 'node'
if (Object.keys(types_versions).length > 0) {
pkg.typesVersions = { '>4.0': types_versions };
}

return pkg;
}

/**
* Rewrite existing path to point to the new output directory
* @param {string} path
* @param {string} out_dir
*/
function prepend_out_dir(path, out_dir) {
if (!path.startsWith(`./${out_dir}`) && path.startsWith('./')) {
return `./${out_dir}/${path.slice(2)}`;
}
}
27 changes: 24 additions & 3 deletions packages/migrate/migrations/package/migrate_pkg.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ test('Updates package.json', () => {
},
exports: {
'./ignored': './something.js'
}
},
svelte: './index.js'
},
[
{
Expand All @@ -37,6 +38,13 @@ test('Updates package.json', () => {
is_included: true,
is_svelte: false
},
{
name: 'bar/index.js',
dest: 'bar/index.js',
is_exported: true,
is_included: true,
is_svelte: false
},
{
name: 'index.js',
dest: 'index.js',
Expand Down Expand Up @@ -77,9 +85,22 @@ test('Updates package.json', () => {
types: './package/baz.d.ts',
default: './package/baz.js'
},
'./ignored': './something.js'
'./bar': {
types: './package/bar/index.d.ts',
svelte: './package/bar/index.js',
default: './package/bar/index.js'
},
'./ignored': './package/something.js'
},
svelte: './package/index.js'
svelte: './package/index.js',
typesVersions: {
'>4.0': {
'foo/Bar.svelte': ['./package/foo/Bar.svelte.d.ts'],
baz: ['./package/baz.d.ts'],
bar: ['./package/bar/index.d.ts'],
ignored: ['./package/something.d.ts']
}
}
});
});

Expand Down