Importing from subfolders for a javascript package
Asked Answered
V

3

27

I have a typescript library consists of multiple folders. Each folder contains an index.ts file which exports some business logic. I am trying to bundle this with rollup to achieve this behavior on the call site:

import { Button, ButtonProps } from 'my-lib/button'
import { Input, Textarea } from 'my-lib/input'
import { Row, Column } from 'my-lib/grid'

This is the directory structure:

enter image description here

I have a main index.ts under src/ which contains:

export * from './button';
export * from './input';
export * from './grid';

With this style, I can do:

import { Button, Input, InputProps, Row, Column } from 'my-lib'

But I don't want this. I want to access to each module by their namespaces. If I remove exports from the index.ts file, all I can do is:

import { Button } from 'my-lib/dist/button'

which is something I didn't see before. Adding dist/ to the import statement means I am accessing the modules via a relative path. I want my-lib/Button.

I am using rollup. I tried to use alias plugin but didn't work. Below is my rollup config:

const customResolver = resolve({
  extensions: ['ts'],
});

export default {
  input: `src/index.ts`,
  output: [
    {
      file: pkg.main,
      format: 'cjs',
      sourcemap: true,
      // plugins: [terser()],
    },
    {
      file: pkg.module,
      format: 'es',
      sourcemap: true,
      plugins: [terser()],
    },
  ],
  // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
  external: [],
  watch: {
    include: 'src/**',
  },
  plugins: [
    // Allow json resolution
    json(),
    // Compile TypeScript files
    typescript({ useTsconfigDeclarationDir: true }),
    // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
    commonjs(),
    // Allow node_modules resolution, so you can use 'external' to control
    // which external modules to include in the bundle
    // https://github.com/rollup/rollup-plugin-node-resolve#usage
    resolve(),

    // Resolve source maps to the original source
    sourceMaps(),
    alias({
      entries: [
        { find: 'my-lib/button', replacement: './dist/button' },
        { find: 'my-lib/input', replacement: './dist/input' },
        { find: 'my-lib/grid', replacement: './dist/grid' },
      ],
      customResolver,
    }),
  ],
};

And this is the tsconfig file:

{
  "compilerOptions": {
    "target": "es5",
    "module": "ES6",
    "lib": ["ES2017", "ES7", "ES6", "DOM"],
    "declaration": true,
    "declarationDir": "dist",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowJs": false,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "baseUrl": "./src",
    "paths": {
      "my-lib/button": ["./src/button"],
      "my-lib/input": ["./src/input"],
      "my-lib/grid": ["./src/grid"]
    }
  },
  "exclude": ["node_modules", "dist", "**/*.test.ts"],
  "include": ["src/**/*.ts"]
}

I don't know how to achieve the same structure as lodash/xxx or material-ui/yyy with rollup.

People suggest aliases or named exports but I couldn't make it work.

The closest thing to my problem is below question:

Import from subfolder of npm package

I want to achieve the same thing but with typescript and rollup.

I think I am missing something, thanks.

Vern answered 22/6, 2020 at 15:48 Comment(0)
C
8

First of all, the only difference between

import { Button } from 'my-lib/dist/button'

and

import { Button } from 'my-lib/button'

is just one more directory level.

Once said that, until you have "outDir": "dist", in your tsconfig.json file you need to add dist/ to your import statements.

Indeed, both the libraries you taken as example are distributed with files in the root directory: lodash directly has js files in the root, while material-ui has not outDir option in its tsconfig.json file (which means to write output files to root directory).

Hope this helps.

Cineaste answered 26/6, 2020 at 1:16 Comment(8)
Let me make sure If I understand correctly: when I installed my library on another project the whole import process relies on the relative paths? There is no magic behind the scenes because of the bundlers? What about those packages like - github.com/rollup/plugins/tree/master/packages/alias or - github.com/tleunen/babel-plugin-module-resolver I thought these packages provide this functionality without moving the files to the root level.Vern
No, there is no magic behind the scenes @alix . There are some differences between import {} from "./path" and import {} from "path" ; I assumed you know about them. I'm not a master with these two packages but it seems they add some magic to import a local library with the second import form. To clarify: how your library (my-lib) is installed on other project? Is it through npm or some other custom way?Cineaste
Yes, it will be installed via npm. This is not my first npm package. I am familiar with the process but this is the first time I tried to implement import from subfolder thing. That's why I am confused. I didn't think that "I move the bundled files to the root dir and bingo"Vern
Yes @alix , that's all. So, if you are using rollup with only this specific purpose, probably you no longer need it.Cineaste
I see. Let me try your way and I will back and release the bounty. Grazie!Vern
OK. You are right, I installed the lodash and the material-ui packages and inspect them under node_modules. They put every sub-module into the root level. That's what I am going to do. To keep my library repository clean, I will create a post-install script so those bundled files will be moved to the root level only when the user installs my package. Thanks again!Vern
I can't release the bounty award for the next 3 hours. We have to wait.Vern
We can wait for 3 hours :) thank you. Happy I have been useful!Cineaste
S
28

This is possible, but requires some extra steps. A mentioned above, this is the approach taken by Material-UI.

The trick is to publish a curated dist folder, rather the root folder of your repo.

Building

To begin with, let's just be clear that it doesn't matter whether your library is built using CommonJS or ESM. This is about module resolution.

Let's assume the project is called my-package.

Now most projects, after we have built src/ to dist/ will have

my-package
  package.json
  src/
    index.js
  dist/
    index.js

and in package.json

"main": "dist/index.js"

or for esm

"module": "dist/index.js"

Publishing

Most projects just add .npmignore and publish the root of the project, so when installed the project ends up in node_modules like so:

node_modules
  my-package/
    package.json
    dist/
      index.js

Resolving

Once installed, consider this import:

import myProject from "my-project";

The module resolver will do this (simplifying greatly, as the full algorithm is irrelevant here):

  • Go to node_modules
  • Find my-project
  • Load package.json
  • Return the file in main or module

Which will work because we have

node_modules
  my-package/
    package.json
    dist/
      index.js

Resolving subpaths

import something from "my-project/something";

The resolution algorithm will work with

node_modules
  my-project/
    somthing.js

also with

node_modules
  my-project/
    something/
      index.js

and with

node_modules
  my-project/
    something/
      package.json

where in the latter case it will again look at main or module.

But we have:

node_modules
  my-package/
    package.json
    dist/
      index.js

The Trick

The trick is, instead of publishing your project root with its dist folder, to "frank" the dist folder and publish the dist folder using npm publish dist instead.

Frank (as in frank a letter) means you need to create a package.json in your dist folder; add README.md LICENSE etc.

A fairly short example of how this is done can be found here.

So, given we had after build:

node_modules
  my-package/
    package.json
    dist/
      index.js
      something.js

Once published we get

node_modules
  my-project/
    package.json
    index.js
    something.js

Where package.json is the curated one.

Stringency answered 8/3, 2021 at 21:11 Comment(3)
Hey, that made a lot of sense to me. One question is how do you approach developing the library locally together with the consumer module using npm link? In that case, the imports would not work because we would have the original source code structure instead of the distributed one at the top-level.Ninny
Good question. You should run the npm link in the dist folder, not the root. Makes sense?Stringency
Thank you for this answer. The custom dist folder approach worked. Worth mentioning is that simply copying your existing package.json will not work as expected if you rely on lots of pre* and post* scripts to run when doing npm publish, as they will have PWD set to your dist folder, instead of your root folder! Here's one of the fixup commits I had to do: github.com/fatso83/retry-dynamic-import/commit/…Elroy
C
8

First of all, the only difference between

import { Button } from 'my-lib/dist/button'

and

import { Button } from 'my-lib/button'

is just one more directory level.

Once said that, until you have "outDir": "dist", in your tsconfig.json file you need to add dist/ to your import statements.

Indeed, both the libraries you taken as example are distributed with files in the root directory: lodash directly has js files in the root, while material-ui has not outDir option in its tsconfig.json file (which means to write output files to root directory).

Hope this helps.

Cineaste answered 26/6, 2020 at 1:16 Comment(8)
Let me make sure If I understand correctly: when I installed my library on another project the whole import process relies on the relative paths? There is no magic behind the scenes because of the bundlers? What about those packages like - github.com/rollup/plugins/tree/master/packages/alias or - github.com/tleunen/babel-plugin-module-resolver I thought these packages provide this functionality without moving the files to the root level.Vern
No, there is no magic behind the scenes @alix . There are some differences between import {} from "./path" and import {} from "path" ; I assumed you know about them. I'm not a master with these two packages but it seems they add some magic to import a local library with the second import form. To clarify: how your library (my-lib) is installed on other project? Is it through npm or some other custom way?Cineaste
Yes, it will be installed via npm. This is not my first npm package. I am familiar with the process but this is the first time I tried to implement import from subfolder thing. That's why I am confused. I didn't think that "I move the bundled files to the root dir and bingo"Vern
Yes @alix , that's all. So, if you are using rollup with only this specific purpose, probably you no longer need it.Cineaste
I see. Let me try your way and I will back and release the bounty. Grazie!Vern
OK. You are right, I installed the lodash and the material-ui packages and inspect them under node_modules. They put every sub-module into the root level. That's what I am going to do. To keep my library repository clean, I will create a post-install script so those bundled files will be moved to the root level only when the user installs my package. Thanks again!Vern
I can't release the bounty award for the next 3 hours. We have to wait.Vern
We can wait for 3 hours :) thank you. Happy I have been useful!Cineaste
P
4

After numerous trials and errors, I was able to get this working by passing in a list of inputs, using the preserveModules and preserveModulesRoot options, and a simple postinstall script.

Here's my rollup.config.js

const options = {
  input: [
    'src/index.ts',
    'src/api/index.ts',
    'src/components/index.ts',
    'src/contexts/index.ts',
    'src/hooks/index.ts',
    'src/lib/index.ts',
    'src/types/index.ts',
    'src/utils/index.ts',
    'src/UI/index.ts',
  ],
  output: [
    {
      format: 'cjs',
      dir: 'dist',
      exports: 'auto',
      preserveModules: true,
      preserveModulesRoot: 'src',
      sourcemap: true,
    },
  ],
  plugins: [
    // Preferably set as first plugin.
    peerDepsExternal(),
    typescript({
      tsconfig: './tsconfig.rollup.json',
    }),
    postcss({
      extract: false,
      modules: true,
      use: ['sass'],
    }),
  ],
};

export default options;

scripts/postinstall.sh

#!/usr/bin/env bash
set -e;

# skip postinstall if npm install for development
# rollup.config.js is not included in dist
if [ -f "rollup.config.js" ]; then
  echo "skipping your package's postinstall routine.";
  exit 0;
fi

echo 'Copying files from dist folder into root project folder...'
cp -r dist/* ./ && rm -rf dist
echo 'Postinstall done!'

package.json

"scripts": {
    "postinstall": "./scripts/postinstall.sh",
  },

This will compile and output all files to dist folder. The postinstall script will copy all files from dist into the root project folder.

Note*: The postinstall script should be skipped when running npm install locally. This is done by checking if rollup.config.js exists or not.

Peppi answered 6/12, 2020 at 22:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.