0

I'm trying to add WebVitals to static HTML pages. The WebVitals docs say, reasonably enough,

while it's certainly possible to inline the code in dist/polyfill.js by copy and pasting it directly into your templates, it's better to automate this process in a build step—otherwise you risk the "base" and the "polyfill" scripts getting out of sync when new versions are released.

(they insist that the polyfill be inline, right after the <head>).

Right now, my build environment is WebPack 5 and EJS.

I suspect the correct way to do this is with a custom HtmlWebpackPlugin template, as described in fair detail in this answer, and documented here, combined with the HtmlWebpackPlugin inline example; however, the data passed to the template file has changed since that answer was written, and the inline example is for pug, not ejs/lodash. I think I'm on the right path here, but can't quite get it to work.

First, I'm adding custom options to the HtmlWebpackPlugin config in webpack.config.js (full config below):

new HtmlWebpackPlugin({
  template: path.resolve(__dirname, './views/prague-a.ejs'),
  filename: path.resolve(__dirname, 'prague-a.html'),
  inlineHeadScripts: [ 'inlineWebvitals' ]
}),

Then in the template file, I grab the source from the matching script:

<% htmlWebpackPlugin.options.inlineHeadScripts.forEach((script) => { %>
<script>
  <%
    const target = htmlWebpackPlugin.files.js.filter((file) => file.match(`/${script}/`))[0];
    // !compilation.assets[target.substr(htmlWebpackPlugin.files.publicP  ath.length)].source()
    !compilation.assets['inlineWebvitals.bundle.js'].source()
  %> 
</script>
<% }) %>

a) I can't quite get the match to work, which is why I've hardcoded the filename for now; and b) even with the hardcoded file name, it's not injecting the code.

If I run it exactly as above, or with {compilation.assets['inlineWebvitals.bundle.js'].source()}, then it parses but doesn't insert anything:

  <script>
    
  </script>

If I do !{compilation.assets['inlineWebvitals.bundle.js'].source()}, which is what the pug template does, then it throws an error:

ERROR in   Error: Child compilation failed:
  Module build failed (from ./node_modules/html-webpack-plugin/lib/loader.js):
  SyntaxError: Unexpected token '.'
      at Function (<anonymous>)
      ...

So, the question is a) am I actually on the right track here, in terms of how to inject the output from WebPack into the html? And b) if yes, how do I actually get that last line to work so that it injects the source?

Epilogue: Why Not Just Use A Plugin?

There are several questions on here about inlining HTML with WebPack (e.g., Inline JavaScript and CSS with webpack and How to make webpack bundle js directly in script tag, not via src). Both of those have relatively recent answers which suggest using InlineChunkHtmlPlugin from react-dev-utils; however, the docs for that explicitly say that it works with HtmlWebpackPlugin 4.x, and, in point of fact, it appears not to work with WebPack 5. It doesn't throw any errors; it just simply doesn't do anything. I also tried html-inline-script-webpack-plugin, which claims to be inspired by react-dev-utils and support WebPack 5, but the result was the same — the webpack build just includes a src link in the script. For example,

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin');

module.exports = (env, argv) => {
  console.log('Webpack mode: ', argv.mode);

  const config = {
    entry: {
      index: [
        path.resolve(__dirname, './src/sendtolog.js'),
        path.resolve(__dirname, './src/webvitals.js')
      ],
      inlineWebvitals:  path.resolve(__dirname,
        './node_modules/web-vitals/dist/polyfill.js')
    },
    module: {
      rules: [
        // JavaScript
        {
          test: /\.(js)$/,
          exclude: /node_modules/,
          use: ['babel-loader']
        },
      ]
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname, './views/prague-a.ejs'),
        filename: path.resolve(__dirname, 'prague-a.html')
      }),
      new HtmlInlineScriptPlugin({
        scriptMatchPattern: [/inline.+[.]js$/]
      }),
      // also tried default behavior, inline every script:
      // new HtmlInlineScriptPlugin(),
      // identical behavior with the react script:
      // new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/inline/]),
      new CleanWebpackPlugin()
    ],
    output: {
      path: path.resolve(__dirname, './dist'),
      filename: '[name].bundle.js'
    },
    optimization: {
      splitChunks: {
        chunks: 'all'
      }
    },
    mode: argv.mode
  }

  return config;
};

But it insists on not inlining the source:

% rm -rf dist
% rm prague-a.html
% npx webpack-cli --mode development
// full output at end of post
% ls dist
index.bundle.js         inlineWebvitals.bundle.js
% grep inline prague-a.html 
    <script defer src="dist/index.bundle.js"></script><script defer src="dist/inlineWebvitals.bundle.js"></script></head>
// just to make sure it didn't both do the above and also inline it somewhere:
% grep -l performance dist/inlineWebvitals.bundle.js
dist/inlineWebvitals.bundle.js
% grep -l performance prague-a.html
%

I've tried all the variations on the theme I can imagine (a few are in the comments in the webpack config) and the results are always the same.

But even if I could get it to work, it seems that none of these plugins give you control over where to inject the scripts (or, more specifically, you can inject in either the head or the body, but there doesn't seem to be a way to put some in one place and others in another). So, while it might actually be the simplest solution (if it were working), I think the path I'm down above is a better / more general solution.

Epilogue II: The EJS Hack

For this specific case, of just injecting one script, I've managed to get it to work with some EJS + NPM hackery, but this doesn't feel like a general/scalable/sustainable solution.

HtmlWebpackPlugin takes an EJS template as input. I can use EJS itself to pre-parse the EJS template.

  1. Copy the code you want to inline into the EJS path, and change the extension to .ejs
  2. Take your base template, and use the EJS option for a custom delimiter and an include call
  3. Output from EJS to a new template, which will be used by WebPack

package.json:

"build:dev:views": "cp node_modules/web-vitals/dist/polyfill.js views/build/polyfill.ejs && ejs -m '|' views/prague-a.ejs -o build/prague-a.ejs"

and in the template

<script>
  <|- include('build/polyfill') |>
</script>
<% //other EJS stuff that will be parsed by WebPack %>

and then in webpack.config.js:

new HtmlWebpackPlugin({
  template: path.resolve(__dirname, './views/build/prague-a.ejs'),
  filename: path.resolve(__dirname, 'prague-a.html'),
}),

If you wanted to, you could even reverse this process. That is, have NPM run WebPack first, and then use EJS to include the production built / minified files. But again, this doesn't feel sustainable or scalable.

Appendix: Full output of build

Webpack mode:  development
asset ../prague-a.html 105 KiB [compared for emit]
asset index.bundle.js 18.6 KiB [emitted] (name: index)
asset inlineWebvitals.bundle.js 2.53 KiB [emitted] (name: inlineWebvitals)
orphan modules 15.9 KiB [orphan] 10 modules
runtime modules 670 bytes 3 modules
cacheable modules 8.9 KiB
  modules by path ./node_modules/ 8.12 KiB
    modules by path ./node_modules/uuid/dist/esm-browser/*.js 3.24 KiB
      ./node_modules/uuid/dist/esm-browser/v4.js 544 bytes [built] [code generated]
      ./node_modules/uuid/dist/esm-browser/rng.js 1.02 KiB [built] [code generated]
      + 3 modules
    modules by path ./node_modules/web-vitals/ 4.88 KiB
      ./node_modules/web-vitals/dist/polyfill.js 1.15 KiB [built] [code generated]
      ./node_modules/web-vitals/base.js 173 bytes [built] [code generated]
      ./node_modules/web-vitals/dist/web-vitals.base.js 3.56 KiB [built] [code generated]
  modules by path ./src/*.js 792 bytes
    ./src/sendtolog.js 641 bytes [built] [code generated]
    ./src/webvitals.js 151 bytes [built] [code generated]
webpack 5.73.0 compiled successfully in 1087 ms
philolegein
  • 1,099
  • 10
  • 28

1 Answers1

0

It turns out the correct EJS/lodash syntax to output the source from the function was to just put it in its own eval container: <%= %>.

I still don't know why evaluating the script directly in the .match wasn't working, but putting it in a new RegExp solved that part of the problem. So, in total, this works:

  <!DOCTYPE html>
  <html lang="en-US" class="no-js">
      <head>
        <% htmlWebpackPlugin.options.inlineHeadScripts.forEach((script) => { %>
        <script>
          <%
            const re = new RegExp(script);
            const target = htmlWebpackPlugin.files.js.filter((file) =>
               file.match(re))[0];
          %>
            <%= compilation.assets[target
                  .substr(htmlWebpackPlugin.files.publicPath.length)].source()
            %>
        </script>
        <% }) %>
philolegein
  • 1,099
  • 10
  • 28