html-bundler-webpack-plugin icon indicating copy to clipboard operation
html-bundler-webpack-plugin copied to clipboard

[Feature Request] Support for helpers in handlebars in compile mode

Open Bessonov opened this issue 1 year ago • 10 comments

Feature request

What is motivation or use case for adding/changing the behavior?

Handlebars supports helpers to extend its functionality. For example, there are no comparison or coalesce operators besides the if statement, which expects a boolean value. This limitation leads to boilerplate code that converts every statement like {{#if (eq value "apple")}} to template({isApple: value === "apple"}) and {{#if isApple}}. Using it with HTML controls to set selected in <select><option ... makes the situation even worse.

Additionally, handlebars-loader supports this feature via the helperDirs option. Implementing this would improve feature parity between the two.

Describe the solution you'd like I would love for helpers to be compiled into the template function, allowing them to be used in imported templates.

Side note: handlebars-loader doesn’t allow inline scripts in the configuration like this plugin does. I think this is a reasonable tradeoff to enable compiling helpers into the template.

Side note 2: It seems like this plugin doesn’t support ESM helpers:

Error [ERR_REQUIRE_ESM]: require() of ES Module /home/vagrant/workspace/website/src/handlebars/coalesce.js from /home/vagrant/workspace/website/.cache/pnpm/virtual-store-dir/[email protected][email protected][email protected]/node_modules/html-bundler-webpack-plugin/src/Loader/Preprocessors/Handlebars/index.js not supported.
Instead change the require of coalesce.js in /home/vagrant/workspace/website/.cache/pnpm/virtual-store-dir/[email protected][email protected][email protected]/node_modules/html-bundler-webpack-plugin/src/Loader/Preprocessors/Handlebars/index.js to a dynamic import() which is available in all CommonJS modules.

Describe alternatives you've considered

  • Introducing is* variables.
  • Using handlebars-loader without inheritance.

Appreciation for the useful project

  • [⭐] After the feature is implemented, do not forget to give a star ⭐

Thank you!

Bessonov avatar Dec 08 '24 13:12 Bessonov

@Bessonov please try the v4.10.0.

Note: the helpers should not contains code for Node.JS or file system, e.g. path.resolve(), fs.readFile(), etc., because the helper's code will be called in browser runtime.

If the helpers option contains a directory to helper files, then helpers must be in CJS format. ESM is not supported.

If you have helpers in ESM format, then you can import in webpack config once and define the helpers option as the object:

const HtmlBundlerPlugin = require('@test/html-bundler-webpack-plugin');
const Handlebars = require('handlebars');
import { h1, italic } from './src/helpers/html-helpers';

module.exports = {
  plugins: [
    new HtmlBundlerPlugin({
      preprocessor: 'handlebars',
      preprocessorOptions: {
        helpers: {
          // imported ESM helpers
          h1,
          italic,

          // you can manual create a small simple helpers as a funciton:
          bold(options) {
            return new Handlebars.SafeString(`<strong>${options.fn(this)}</strong>`);
          },
        },
        partials: ['src/partials'],
      },
    }),
  ],
};

There are ESM helpers in ./src/helpers/html-helpers:

//import Handlebars from 'handlebars'; // ACHTUNG: this not works!
const Handlebars = require('handlebars');

export const h1 = function (options) {
  return new Handlebars.SafeString(`<h1>${options.fn(this)}</h1>`);
};

export const italic = function (options) {
  return new Handlebars.SafeString(`<i>${options.fn(this)}</i>`);
};

webdiscus avatar Dec 08 '24 22:12 webdiscus

Hey @webdiscus,

Thank you! It resolves my use case!

If I understand it correctly, you are stringifying functions, which comes with several limitations. For example, I encountered the following issues:

  • Semicolons are mandatory (I don't use them, so an exclusion for dprint is needed).
  • Comments inside the helper functions aren't possible.
  • require doesn't work as expected (though it works if I provide the correct path where the template is executed, such as an absolute path).

The last point makes using libraries like handlebars-helpers more difficult. I believe they can be used if each helper is compiled into a single function (=without require) in some sort of preprocessing step.

In addition, my webpack configuration is esm-based, which made it a little bit more challenging to find a working combination of commonjs and esm. This setup works for me:

// webpack.config.mjs
import eq from './src/handlebars/eq.cjs'

// eq.cjs
// https://stackoverflow.com/questions/8853396/logical-operator-in-a-handlebars-js-if-conditional/31632215#31632215
module.exports = function () {
	function reduceOp(args, reducer) {
		args = Array.from(args);
		args.pop();
		const first = args.shift();
		return args.reduce(reducer, first);
	};
	return reduceOp(arguments, (a, b) => a === b);
}

Note: the helpers should not contains code for Node.JS or file system, e.g. path.resolve(), fs.readFile(), etc., because the helper's code will be called in browser runtime.

Nope, my runtime is still node :)

console.log(require('fs').readFileSync('/tmp/test'));

works just fine inside helpers! :smile:

Bessonov avatar Dec 09 '24 22:12 Bessonov

@Bessonov

If I understand it correctly, you are stringifying functions, which comes with several limitations. For example, I encountered the following issues

yes, functions will be stringified. By stringify the source code can be modified. I don't know other way how to include helpers code into stringified template function when helpers are defined in options as a custom functions. This is the feature of the plugin.

I have an idea, if a helpers option is a path to helpers directory, then I can read source code as text directly from helper files and inject into template function as the string. I need to do an experiment.

webdiscus avatar Dec 09 '24 22:12 webdiscus

@Bessonov If I understand, my solution works only partially and can not cover all your use cases?

webdiscus avatar Dec 09 '24 22:12 webdiscus

@webdiscus

If I understand, my solution works only partially and can not cover all your use cases?

At the moment, all my use cases are covered - thank you! (Of course, duplicating functions like reduceOp isn't best practice, but since it's in a very limited area, it's not an issue!)

I haven't explored the internals of handlebars-loader, but perhaps this could be useful for you: https://github.com/pcardune/handlebars-loader/blob/e678ba7aec1eb56bf70767ff1c11ef39281c22b7/index.js#L88

I have an idea, if a helpers option is a path to helpers directory, then I can read source code as text directly from helper files and inject into template function as the string. I need to do an experiment.

If I understand you correctly, this would solve the issue if everything is in the helpers folder, but not if some helpers require/import external modules. (At least after packaging the project, there will be no node_modules anymore, so require('whatever') wouldn't work anymore).

I don’t know much about webpack internals, so this might be a naïve question: could you use webpack for compiling helpers? Since you already control the entry points, maybe webpack could compile the entire chain of required/imported modules and include it in the compiled bundle like:

/***/ "./build/handlebars/utils.mjs":
/*!************************************!*\
  !*** ./build/handlebars/utils.mjs ***!
  \************************************/
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   reduceOp: () => (/* binding */ reduceOp)
/* harmony export */ });
// https://stackoverflow.com/questions/8853396/logical-operator-in-a-handlebars-js-if-conditional/31632215#31632215
function reduceOp(args, reducer) {
    args = Array.from(args);
    args.pop(); // => options
    const first = args.shift();
    return args.reduce(reducer, first);
}
//# sourceMappingURL=utils.mjs.map

/***/ }),

, so the helpers would "magically" work?

Bessonov avatar Dec 09 '24 23:12 Bessonov

could you use webpack for compiling helpers?

Webpack already parses the JS code generated by the plugin, resolves all require and import statements, and injects the compiled code into the resulting compiled JS function. The magic of Webpack is already working.

webdiscus avatar Dec 09 '24 23:12 webdiscus

this would solve the issue if everything is in the helpers folder, but not if some helpers require/import external modules. (At least after packaging the project, there will be no node_modules anymore, so require('whatever') wouldn't work anymore).

I have now other idea... I will generate the code for each helper founded in defined heplers directory:

`let helper = require('${helperFile}'); 
Handlebars.registerHelper('${name}', helper);`

So, Webpack compile all required files into code, including all required/imported modules from node_modules.

webdiscus avatar Dec 09 '24 23:12 webdiscus

I haven't explored the internals of handlebars-loader, but perhaps this could be useful for you: https://github.com/pcardune/handlebars-loader/blob/e678ba7aec1eb56bf70767ff1c11ef39281c22b7/index.js#L88

The handlebars-loader does the same as I wrote above:

Line: 112
----
 else if (type === "helper") {
      if (foundHelpers["$" + name]) {
        return (
          "__default(require(" +
          loaderUtils.stringifyRequest(loaderApi, foundHelpers["$" + name]) +
          "))"
        );
      }
      foundHelpers["$" + name] = null;

webdiscus avatar Dec 10 '24 00:12 webdiscus

Hm, I assumed that your require in the examples:

const Handlebars = require('handlebars');

works, because you put it in the compiled template here: https://github.com/webdiscus/html-bundler-webpack-plugin/blob/33c99f3f7e837d0d1c6765699e41ae0dd2fad074/src/Loader/Preprocessors/Handlebars/index.js#L268

The compiled helper version is without the require (I think because only the function itself is stringified, everything around it is, probably, ignored):

        var Handlebars = __webpack_require__(/*! ./.cache/pnpm/virtual-store-dir/[email protected]/node_modules/handlebars/runtime.js */ "./.cache/pnpm/virtual-store-dir/[email protected]/node_modules/handlebars/runtime.js");
        var data = {};

          Handlebars.registerHelper('italic', function (options) {  return new Handlebars.SafeString(`<i>${options.fn(this)}</i>`);});
          Handlebars.registerHelper('eq', function () { function reduceOp(args, reducer) {              args = Array.from(args);                args.pop();             const first = args.shift();             return args.reduce(reducer, first);     };      return reduceOp(arguments, (a, b) => a === b);});

At least I couldn't get require to work on another file.

Bessonov avatar Dec 10 '24 00:12 Bessonov

I haven't explored the internals of handlebars-loader, but perhaps this could be useful for you: https://github.com/pcardune/handlebars-loader/blob/e678ba7aec1eb56bf70767ff1c11ef39281c22b7/index.js#L88

The handlebars-loader does the same as I wrote above: [..]

You mean the result? Sure! But it seems that the way is different. For example, handlebars-loader can handle esm imports/exports. I found this document: https://github.com/handlebars-lang/handlebars.js/blob/master/docs/compiler-api.md

Bessonov avatar Dec 10 '24 00:12 Bessonov