How i ditched webpack and wrote babel-plugin for scss / sass transpile

Background



One Saturday night I was sitting and looking for ways to build a UI-Kit using webpack. I use styleguidst as a UI-kit demo. Of course, webpack is smart and it stuffs all the files that are in the working directory into one bundle and from there everything turns and turns.



I created an entry.js file, imported all the components there, then exported from there. It seems everything is ok.



import Button from 'components/Button'
import Dropdown from 'components/Dropdown '

export {
  Button,
  Dropdown 
}


And after assembling all this, I got output.js, in which, as expected, everything was - all the components on the heap in one file. Here the question arose:

How can I collect all the buttons, dropdowns, and so on separately, which would be imported in other projects?

But I also want to upload it to npm as a package.



Hmm ... Let's go in order.



Multiple entries



Of course, the first idea that comes to mind is to parse all the components in the working directory. I had to google a bit about parsing files, because I rarely work with NodeJS. Found such a thing as glob .



We drove to write multiple entries.



const { basename, join, resolve } = require("path");
const glob = require("glob");

const componentFileRegEx = /\.(j|t)s(x)?$/;
const sassFileRegEx = /\s[ac]ss$/;

const getComponentsEntries = (pattern) => {
  const entries = {};
  glob.sync(pattern).forEach(file => {
    const outFile = basename (file);
    const entryName = outFile.replace(componentFileRegEx, "");
    entries[entryName] = join(__dirname, file);
  })
  return entries;
}

module.exports = {
  entry: getComponentsEntries("./components/**/*.tsx"),
  output: {
    filename: "[name].js",
    path: resolve(__dirname, "build")
  },
  module: {
    rules: [
      {
        test: componentFileRegEx,
        loader: "babel-loader",
        exclude: /node_modules/
      },
      {
        test: sassFileRegEx,
        use: ["style-loader", "css-loader", "sass-loader"]
      }
    ]
  }
  resolve: {
    extensions: [".js", ".ts", ".tsx", ".jsx"],
    alias: {
      components: resolve(__dirname, "components")
    }
  }
}


Done. We collect.



After the build, 2 Button.js files, Dropdown.js fell into the build directory - let's look inside. Inside the license is react.production.min.js, hard-to-read minified code, and a lot of bullshit. Okay, let's try using the button.



In the demo file of the button, change the import to import from the build directory.



This is how a simple demo of a button looks like in styleguidist - Button.md



```javascript
import Button from '../../build/Button'
<Button></Button>
```


We go to look at the IR button ... At this stage, the idea and desire to collect through webpack have already disappeared.



Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.









Looking for another build path without webpack



We go for help to a babel without a webpack. We write a script in package.json, specify the config file, extensions, the directory where the components are located, the directory where to build:



{
  //...package.json  -     
  scripts: {
    "build": "babel --config-file ./.babelrc --extensions '.jsx, .tsx' ./components --out-dir ./build"
  }
}


run:



npm run build


Voila, we have 2 files Button.js, Dropdown.js in the build directory, inside the files there is beautifully designed vanilla js + some polyfills and a lonely requre ("styles.scss") . Obviously, this will not work in the demo, remove the import of styles (at that moment I was gnawing at the hope that I would find a plugin for the scss transpile), and collect it again.



After the assembly, we still have some nice JS. Let's try again to integrate the assembled component into the styleguidist:



```javascript
import Button from '../../build/Button'
<Button></Button>
```


Compiled - it works. Only a button without styles.



We are looking for a plugin for transpile scss / sass



Yes, the assembly of components works, the components are working, you can build, publish in npm or your own working nexus. Still, just keep the styles ... Okay, Google will help us again (no).



Googling the plugins didn't bring me any results. One plugin generates a string from styles, the other does not work at all, and even requires import of the view: import styles from "styles.scss"



The only hope was for this plugin: babel-plugin-transform-scss-import-to-string, but it just generates a line from styles (ah ... I already said above. Damn ...). Then everything got even worse, I reached page 6 in Google (and the clock is already 3 in the morning). And there will be no particular options to find something. Yes, and there is nothing to think about - either webpack + sass-loader, which suck at doing it and not for my case, or SOMETHING OTHER. Nerves ... I decided to take a break, drink tea, I still don't want to sleep. While I was making tea, the idea of ​​writing a plug-in for the scss / sass transpile came into my head more and more. While the sugar was stirring, the occasional ringing of a spoon in my head echoed: "Write plaagin." Ok, decided, I'll write a plugin.



Plugin not found. We write ourselves



I took the babel-plugin-transform-scss-import-to-string mentioned above as the basis for my plugin . I perfectly understood that now there will be hemorrhoids with an AST tree, and other tricks. Okay, let's go.



We make preliminary preparations. We need node-sass and path, as well as regular lines for files and extensions. The idea is this:



  • We get the path to the file with styles from the import line
  • Parse styles to string via node-sass (thanks to babel-plugin-transform-scss-import-to-string)
  • We create style tags for each of the imports (the babel plugin is launched on each import)
  • It is necessary to somehow identify the created style, so as not to throw the same thing on every hot-reload sneeze. Let's push it some attribute (data-sass-component) with the value of the current file and the name of the stylesheet. There will be something like this:



          <style data-sass-component="Button_style">
             .button {
                display: flex;
             }
          </style>
    


In order to develop the plugin and test it on the project, at the level with the components directory, I created a babel-plugin-transform-scss directory, stuffed package.json there and stuffed the lib directory there, and I already threw index.js into it.

What would you be vkurse - Babel config climbs behind the plugin, which is specified in the main directive in package.json, for this I had to cram it.
We indicate:



{
  //...package.json   -     ,    main  
  main: "lib/index.js"
}


Then, push the path to the plugin into the babel config (.babelrc):



{
  //  
  plugins: [
    "./babel-plugin-transform-scss"
    //    
  ]
}


Now, let's cram some magic into index.js.



The first stage is to check for the import of the scss or sass file, get the name of the imported files, get the name of the js file (component) itself, transported the scss or sass string to css. We cut through WebStorm to npm run build through a debugger, set breakpoints, look at the path and state arguments and fish out the file names, process them with curses:



const { resolve, dirname, join } = require("path");
const { renderSync } = require("node-sass");

const regexps = {
  sassFile: /([A-Za-z0-9]+).s[ac]ss/g,
  sassExt: /\.s[ac]ss$/,
  currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g,
  currentFileExt: /.(t|j)s(x)/g
};

function transformScss(babel) {
  const { types: t } = babel;
  return {
    name: "babel-plugin-transform-scss",
    visitor: {
      ImportDeclaration(path, state) {
        /**
         * ,     scss/sass   
         */
        if (!regexps.sassExt.test(path.node.source.value)) return;
        const sassFileNameMatch = path.node.source.value.match(
          regexps.sassFile
        );

        /**
         *    scss/sass    js 
         */
        const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, "");
        const file = this.filename.match(regexps.currentFile);
        const filename = `${file[0].replace(
          regexps.currentFileExt,
          ""
        )}_${sassFileName}`;

        /**
         *
         *     scss/sass ,    css
         */
        const scssFileDirectory = resolve(dirname(state.file.opts.filename));
        const fullScssFilePath = join(
          scssFileDirectory,
          path.node.source.value
        );
        const projectRoot = process.cwd();
        const nodeModulesPath = join(projectRoot, "node_modules");
        const sassDefaults = {
          file: fullScssFilePath,
          sourceMap: false,
          includePaths: [nodeModulesPath, scssFileDirectory, projectRoot]
        };
        const sassResult = renderSync({ ...sassDefaults, ...state.opts });
        const transpiledContent = sassResult.css.toString() || "";
        }
    }
}


Fire. First success, got the css line in transpiledContent. Next, the worst thing - we climb into babeljs.io/docs/en/babel-types#api for the API on the AST tree. We climb into astexplorer.net and write the code for pushing the stylesheet into the head.



In astexplorer.net, write a Self-Invoking function that will be called at the place of the style import:



(function(){
  const styles = "generated transpiledContent" // ".button {/n display: flex; /n}/n" 
  const fileName = "generated_attributeValue" //Button_style
  const element = document.querySelector("style[data-sass-component='fileName']")
  if(!element){
    const styleBlock = document.createElement("style")
    styleBlock.innerHTML = styles
    styleBlock.setAttribute("data-sass-component", fileName)
    document.head.appendChild(styleBlock)
  }
})()


In the AST explorer, poke on the left side of lines, declarations, literals, - on the right in the tree we look at the structure of declarations, we climb into babeljs.io/docs/en/babel-types#api using this structure , smoke all this and write a replacement.



A few moments later ...



1-1.5 hours later, running through the tabs from ast to babel-types api, then into the code, I wrote a replacement for the scss / sass import. I will not parse the ast tree and the babel-types api separately, there will be even more letters. I immediately show the result:



const { resolve, dirname, join } = require("path");
const { renderSync } = require("node-sass");

const regexps = {
  sassFile: /([A-Za-z0-9]+).s[ac]ss/g,
  sassExt: /\.s[ac]ss$/,
  currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g,
  currentFileExt: /.(t|j)s(x)/g
};

function transformScss(babel) {
  const { types: t } = babel;
  return {
    name: "babel-plugin-transform-scss",
    visitor: {
      ImportDeclaration(path, state) {
        /**
         * ,     scss/sass   
         */
        if (!regexps.sassExt.test(path.node.source.value)) return;
        const sassFileNameMatch = path.node.source.value.match(
          regexps.sassFile
        );

        /**
         *    scss/sass    js 
         */
        const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, "");
        const file = this.filename.match(regexps.currentFile);
        const filename = `${file[0].replace(
          regexps.currentFileExt,
          ""
        )}_${sassFileName}`;

        /**
         *
         *     scss/sass ,    css
         */
        const scssFileDirectory = resolve(dirname(state.file.opts.filename));
        const fullScssFilePath = join(
          scssFileDirectory,
          path.node.source.value
        );
        const projectRoot = process.cwd();
        const nodeModulesPath = join(projectRoot, "node_modules");
        const sassDefaults = {
          file: fullScssFilePath,
          sourceMap: false,
          includePaths: [nodeModulesPath, scssFileDirectory, projectRoot]
        };
        const sassResult = renderSync({ ...sassDefaults, ...state.opts });
        const transpiledContent = sassResult.css.toString() || "";
        /**
         *  ,   AST Explorer     
         * replaceWith  path.
         */
        path.replaceWith(
          t.callExpression(
            t.functionExpression(
              t.identifier(""),
              [],
              t.blockStatement(
                [
                  t.variableDeclaration("const", [
                    t.variableDeclarator(
                      t.identifier("styles"),
                      t.stringLiteral(transpiledContent)
                    )
                  ]),
                  t.variableDeclaration("const", [
                    t.variableDeclarator(
                      t.identifier("fileName"),
                      t.stringLiteral(filename)
                    )
                  ]),
                  t.variableDeclaration("const", [
                    t.variableDeclarator(
                      t.identifier("element"),
                      t.callExpression(
                        t.memberExpression(
                          t.identifier("document"),
                          t.identifier("querySelector")
                        ),
                        [
                          t.stringLiteral(
                            `style[data-sass-component='${filename}']`
                          )
                        ]
                      )
                    )
                  ]),
                  t.ifStatement(
                    t.unaryExpression("!", t.identifier("element"), true),
                    t.blockStatement(
                      [
                        t.variableDeclaration("const", [
                          t.variableDeclarator(
                            t.identifier("styleBlock"),
                            t.callExpression(
                              t.memberExpression(
                                t.identifier("document"),
                                t.identifier("createElement")
                              ),
                              [t.stringLiteral("style")]
                            )
                          )
                        ]),
                        t.expressionStatement(
                          t.assignmentExpression(
                            "=",
                            t.memberExpression(
                              t.identifier("styleBlock"),
                              t.identifier("innerHTML")
                            ),
                            t.identifier("styles")
                          )
                        ),
                        t.expressionStatement(
                          t.callExpression(
                            t.memberExpression(
                              t.identifier("styleBlock"),
                              t.identifier("setAttribute")
                            ),
                            [
                              t.stringLiteral("data-sass-component"),
                              t.identifier("fileName")
                            ]
                          )
                        ),
                        t.expressionStatement(
                          t.callExpression(
                            t.memberExpression(
                              t.memberExpression(
                                t.identifier("document"),
                                t.identifier("head"),
                                false
                              ),
                              t.identifier("appendChild"),
                              false
                            ),
                            [t.identifier("styleBlock")]
                          )
                        )
                      ],
                      []
                    ),
                    null
                  )
                ],
                []
              ),
              false,
              false
            ),
            []
          )
        );
        }
    }
}


Final joys



Hooray!!! The import was replaced by a call to a function that crammed the style with this button into the head of the document. And then I thought, what if I start this whole kayak through the webpack, mowing the sass-loader? Will it work? Okay, we mow and check. I launch the assembly with a webpack, waiting for an error that I must define a loader for this file type ... But there is no error, everything is assembled. I open the page, look, and the style stuck in the head of the document. It turned out interestingly, I also got rid of 3 style loaders (very happy smile).



If you were interested in the article, please support it with an asterisk on github .



Also a link to the npm package: www.npmjs.com/package/babel-plugin-transform-scss



Note: Outside the article, added a check for importing style by typeimport styles from './styles.scss'



All Articles