Skip to content

Commit e74226f

Browse files
committed
fix: support Handlebars helpers defined as object methods to prevent build failure, #177
1 parent 61f3e80 commit e74226f

22 files changed

Lines changed: 573 additions & 129 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 4.20.7 (2025-06-30)
4+
5+
- fix: support Handlebars helpers defined as object methods to prevent build failure, #177
6+
37
## 4.20.6 (2025-06-29)
48

59
- fix: built fails when used a template variable in srcset with JS-template, #176

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "html-bundler-webpack-plugin",
3-
"version": "4.20.6",
3+
"version": "4.20.7",
44
"description": "Generates complete single-page or multi-page website from source assets. Built-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.",
55
"keywords": [
66
"html",

src/Loader/Utils.js

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,18 +202,141 @@ const stringifyJSON = (data) => {
202202
: json || '{}';
203203
};
204204

205-
const stringifyFn = (fn) => {
206-
let value = fn.toString().replace(/\n/g, '');
207-
let isArrowFunction = value.indexOf('=>', 1) > 0;
205+
/**
206+
* Removes all JavaScript comments (single-line and multi-line)
207+
* from the given source code, while preserving string and template literals.
208+
*
209+
* This function avoids removing comment-like patterns inside string literals
210+
* (single quotes, double quotes, or backticks), and skips escaped characters.
211+
*
212+
* @param {string} code The JavaScript source code to clean.
213+
* @returns {string} The code with all comments removed.
214+
*
215+
* @example
216+
* // Removes only real comments, not content inside strings:
217+
* const input = "const x = 'text // not a comment'; // real comment";
218+
* const output = stripComments(input);
219+
* // => "const x = 'text // not a comment'; "
220+
*/
221+
function stripComments(code) {
222+
let out = '';
223+
let i = 0;
224+
const len = code.length;
225+
let inStr = null; // "'", '"', or '`'
226+
let inBlockComment = false;
227+
let inLineComment = false;
228+
229+
while (i < len) {
230+
const char = code[i];
231+
const next = code[i + 1];
232+
233+
// end of line comment
234+
if (inLineComment && (char === '\n' || char === '\r')) {
235+
inLineComment = false;
236+
out += char;
237+
i++;
238+
continue;
239+
}
240+
241+
// end of block comment
242+
if (inBlockComment && char === '*' && next === '/') {
243+
inBlockComment = false;
244+
i += 2;
245+
continue;
246+
}
247+
248+
if (inLineComment || inBlockComment) {
249+
i++;
250+
continue;
251+
}
252+
253+
// handle string start
254+
if (!inStr && (char === '"' || char === "'" || char === '`')) {
255+
inStr = char;
256+
out += char;
257+
i++;
258+
continue;
259+
}
208260

209-
if (!isArrowFunction) {
210-
const pos = value.indexOf('(');
211-
if (pos > 0 && value.slice(0, pos).trim() !== 'function') {
212-
value = 'function' + value.slice(pos);
261+
// handle string end (skip escaped)
262+
if (inStr) {
263+
out += char;
264+
if (char === '\\') {
265+
out += code[i + 1];
266+
i += 2;
267+
continue;
268+
}
269+
if (char === inStr) {
270+
inStr = null;
271+
}
272+
i++;
273+
continue;
213274
}
275+
276+
// handle line comment start
277+
if (char === '/' && next === '/') {
278+
inLineComment = true;
279+
i += 2;
280+
continue;
281+
}
282+
283+
// handle block comment start
284+
if (char === '/' && next === '*') {
285+
inBlockComment = true;
286+
i += 2;
287+
continue;
288+
}
289+
290+
out += char;
291+
i++;
214292
}
215293

216-
return value;
294+
return out;
295+
}
296+
297+
/**
298+
* Stringify any JavaScript function and remove all comments.
299+
*
300+
* @param {Function} fn - The function to stringify.
301+
* @returns {string|null} The cleaned stringified function or null if not a function or native.
302+
*/
303+
const stringifyFn = (fn) => {
304+
if (typeof fn !== 'function') return null;
305+
306+
try {
307+
const raw = fn.toString();
308+
309+
// skip native or bound functions
310+
if (raw.includes('[native code]') || raw.includes('[object Function]')) {
311+
return null;
312+
}
313+
314+
// safe comment removal
315+
let cleaned = stripComments(raw);
316+
317+
// remove leading indent and join into one line
318+
cleaned = cleaned
319+
.split('\n')
320+
.map((line) => line.trimStart())
321+
.join(' ')
322+
.trim();
323+
324+
// check if it is top-level arrow function
325+
const isArrowFunction = /^(\(?[^=(){};]*\)?)\s*=>/.test(cleaned);
326+
327+
// replace method shorthand to function expression
328+
// Example: getFoo(a, b) { ... } -> function(a, b) { ... }
329+
if (!isArrowFunction) {
330+
const pos = cleaned.indexOf('(');
331+
if (pos > 0 && cleaned.slice(0, pos).trim() !== 'function') {
332+
cleaned = 'function' + cleaned.slice(pos);
333+
}
334+
}
335+
336+
return cleaned;
337+
} catch {
338+
return null;
339+
}
217340
};
218341

219342
module.exports = {

test/cases/_preprocessor/js-tmpl-hbs-compile-helpers-strict/expected/app.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/cases/_preprocessor/js-tmpl-hbs-compile-undefined-var/expected/index.html renamed to test/cases/_preprocessor/js-tmpl-hbs-compile-helpers-strict/expected/index.html

File renamed without changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import tmpl from './partials/content.hbs?lang=en';
2+
3+
const locals = {
4+
name: 'World',
5+
people: ['Alexa <Amazon>', 'Cortana <MS>', 'Siri <Apple>'],
6+
};
7+
8+
document.getElementById('main').innerHTML = tmpl(locals);
9+
10+
console.log('>> app');
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use strict';
2+
3+
const Handlebars = require('handlebars');
4+
5+
// Open/close tag helpers
6+
7+
const bold = function (options) {
8+
return new Handlebars.SafeString(`<b>${options.fn(this)}</b>`);
9+
};
10+
11+
const italic = function (options) {
12+
return new Handlebars.SafeString(`<i>${options.fn(this)}</i>`);
13+
};
14+
15+
// Self-closed tag helpers
16+
17+
// usage: {{getFirstChars 'some text' len="3"}}
18+
const getFirstChars = function (content, options) {
19+
if (typeof content !== 'string') return '';
20+
21+
const len = options.hash.len || content.length;
22+
let out = content.slice(0, len);
23+
24+
return new Handlebars.SafeString(out);
25+
};
26+
27+
// test the helper with complex code
28+
const complexHelper = function (content, options) {
29+
let out = '';
30+
31+
// comment
32+
const escapeHTML = (str) =>
33+
str
34+
.replace(/&/g, '&amp;')
35+
.replace(/</g, '&lt;')
36+
.replace(/>/g, '&gt;')
37+
.replace(/"/g, '&quot;')
38+
.replace(/'/g, '&#39;');
39+
40+
const SEP = /(\s|&nbsp;|<br\s*\/?>)+/gi;
41+
const parts = content.split(SEP).filter(Boolean);
42+
const lastWord = parts.pop() || '';
43+
const firstPart = parts.join('');
44+
45+
if (!firstPart.trim()) {
46+
out = `<p class="title">${escapeHTML(lastWord)}</p>`;
47+
} else {
48+
out =
49+
`<p class="title">` +
50+
`${firstPart}` +
51+
`<span class="inline-flex">` +
52+
`${escapeHTML(lastWord)}` +
53+
`</span>` +
54+
`</p>`;
55+
}
56+
57+
return new Handlebars.SafeString(out);
58+
};
59+
60+
// test helper as arrow function
61+
const trim = (content, options) => {
62+
let out = content;
63+
out = out.trim();
64+
return new Handlebars.SafeString(out);
65+
};
66+
67+
export default {
68+
bold,
69+
italic,
70+
getFirstChars,
71+
complexHelper,
72+
trim,
73+
};

test/cases/_preprocessor/js-tmpl-hbs-compile-undefined-var/src/index.hbs renamed to test/cases/_preprocessor/js-tmpl-hbs-compile-helpers-strict/src/index.hbs

File renamed without changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<h1>Hello {{ name }}!</h1>
2+
3+
<h3>Custom helpers in JS-template</h3>
4+
<ul>
5+
<li>{{#bold}}bold{{/bold}}</li>
6+
<li>{{#italic}}italic{{/italic}}</li>
7+
<li>{{#italic}}italic{{/italic}}</li>
8+
<li>getFirstChars 3: {{getFirstChars 'some text' len="3"}}</li>
9+
<li>'trim' helper as arrow function: {{trim ' Trim spaces around '}}</li>
10+
</ul>
11+
12+
<div><b>complexHelper</b></div>
13+
<div>{{complexHelper 'Text foo "bar"'}}</div>
14+
15+
16+
<h3>Variables in JS-template</h3>
17+
<ul>
18+
{{#each people}}
19+
<li>{{this}}</li>
20+
{{/each}}
21+
</ul>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const path = require('path');
2+
const HtmlBundlerPlugin = require('@test/html-bundler-webpack-plugin');
3+
4+
import myHelpers from './src/helpers/my-helpers.js';
5+
6+
module.exports = {
7+
mode: 'production',
8+
9+
output: {
10+
path: path.join(__dirname, 'dist/'),
11+
},
12+
13+
resolve: {
14+
alias: {
15+
'@images': path.join(__dirname, '../../../fixtures/images'),
16+
},
17+
},
18+
19+
plugins: [
20+
new HtmlBundlerPlugin({
21+
entry: {
22+
index: './src/index.hbs',
23+
},
24+
preprocessor: 'handlebars',
25+
preprocessorOptions: {
26+
strict: true,
27+
helpers: myHelpers,
28+
views: ['src/partials'],
29+
},
30+
data: {
31+
title: 'My Title',
32+
},
33+
}),
34+
],
35+
36+
module: {
37+
rules: [
38+
{
39+
test: /\.(ico|png|jpe?g|svg)$/,
40+
type: 'asset/resource',
41+
generator: {
42+
filename: 'img/[name].[hash:8][ext][query]',
43+
},
44+
},
45+
],
46+
},
47+
};

0 commit comments

Comments
 (0)