webpackhtml-webpack-pluginwebpack-production

Prevent HtmlWebpackPlugin from minimizing line breaks in production


Here is the relevant part of my webpack config:

plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      hash: false, // for testing purposes
      minify: false,
    }),    
  ]

Despite minify: false, HTML becomes one line if mode: 'production'. If mode: 'development', then it's multiple lines. If I change hash: true, then it becomes one line with query string hashes for cache busting, which proves that it reads this configuration object.

But why does it truncate line breaks? I tried to specify an object and set collapseWhitespace and other props instead of minify: false, but it also had no effect, still made it one line. Here is a list of versions from package.json:

"devDependencies": {
    "@babel/cli": "^7.14.5",
    "@babel/core": "^7.14.6",
    "@babel/preset-env": "^7.14.5",
    "babel-loader": "^8.2.2",
    "babel-plugin-angularjs-annotate": "^0.10.0",
    "css-loader": "^5.2.6",
    "css-minimizer-webpack-plugin": "3.0.1",
    "html-loader": "^2.1.2",
    "html-webpack-plugin": "5.3.1",
    "mini-css-extract-plugin": "1.6.0",
    "postcss": "^8.3.4",
    "style-loader": "^2.0.0",
    "webpack": "5.39.0",
    "webpack-cli": "4.7.2",
    "webpack-dev-server": "3.11.2",
    "webpack-merge": "^5.8.0"
  }

EDIT: To clarify, I am referring to truncation of line breaks between the dynamic includes inserted by webpack, which are references to bundled JS and CSS file. All these files appear in exactly one line, no matter if it's production or development build. It seems there is no way to split them line by line, as if they were carefully inserted by a human.

Why is it useful to see each dynamic include in its own line? For example if you want to check the order. Currently the only option is to scroll left/right and try to memorize what appears off screen, which is more cognitive load then reading a list top to bottom, where everything fits on one screen without scrolling.


Solution

  • I see that you are using html-loader. My guess is that it's not the HtmlWebpackPlugin that is doing the minification in your case since you set minify: false, but html-loader.

    In order to stop html-loader from minifying we can set something like this:

    test: /\.html$/,
    use: {
      loader: "html-loader",
      options: {
        minimize: false,
      },
    }
    

    However, since you also want to have the .js and .css includes in the generated .html file on separate lines we run into a problem. The reason they are on the same line is because they are injected by HtmlWebpackPlugin without any line breaks in the first place. As far as I know there is no option for this that we could set.

    Solution 1

    Step 1

    The best solution that I've found is to create our template file using EJS (https://ejs.co/) instead of plain html. This allows us to write javascript directly into the template and gives us complete control over how the includes are injected. See this answer.

    Start by renaming your html template file to something like template.ejs. Next add this right before the closing </head> tag:

    for (const tag of htmlWebpackPlugin.tags.headTags) { %>
      <%= tag %><%
    } %>
    

    Make sure not to have auto-format enabled since we rely on the line breaks here to generate the correct output format. Depending on what version of HtmlWebpackPlugin you are using you might also use bodyTags in addition to headTags. It seems to me that at least in latest version they have chosen to put script includes in the head with a defer flag in order to delay execution, which is also the most performant way of doing it as far as I understand it.

    Step 2

    Next we need change the path of the template in the HtmlWebpackPlugin options, and also disable the auto injection of includes like so:

    new HtmlWebpackPlugin({
      template: "./src/template.ejs",
      inject: false
    }),
    

    Step 3

    Since HtmlWebpackPlugin has a built in fallback .ejs parser we don't need to install any extra loader for that. The only problem is that since you are using the html-loader I assume you have assets in your template file like images etc. If you were to create a new rule that would test for .ejs files and use html-loader to process them it would indeed work to replace hard coded src includes with dynamic ones as is the purpose of the loader. However, specifying a loader for .ejs files would stop HtmlWebpackPlugin from using it's fallback parser and thus all the template variables (those inside <% %>) to be left unparsed.

    However we actually don't need html-loader at all. Instead what you can do is to use EJS to require your assets in the template file like so:

    <img src="<%= require('./assets/myimage.jpg') %>" />
    

    Solution 2

    In case for some reason you need / prefer to use the html-loader instead there is another way to go about the issue.

    Since HtmlWebpackPlugin won't use it's fallback EJS parser when html-loader is activated we need to parse it using another loader, called ejs-loader. There is a slight issue though: when html-loader parses the template file it generates javascript. Since ejs-loader don't expect to receive javascript we need to first convert it back to EJS format. This can be done by yet another loader called extract-loader.

    Start by following Step 1 from Solution 1. Next we need to install the two new loaders:

    yarn add -D ejs-loader extract-loader
    

    After that we add them to the module rules in the webpack config. Make sure to set esModule to false also for the html-loader since extract-loader can't handle that:

    module: {
    rules: [
      {
        test: /\.ejs$/,
        use: [
        {
          loader: "ejs-loader",
          options: {
            esModule: false
          }
          },
          'extract-loader',
          {
          loader: "html-loader",
          options: {
            esModule: false
          },
        }]
      },
    
      ...
    

    This setup starts with html-loader that automatically parses any src includes and converts it to require statements. It generates javascript code that extract-loader converts back to EJS. Finally ejs-loader parses the template variables, in this case the list of js / css includes.