From 5321bd6bd98af2c25b4d0b5425223d04bff1cfc2 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 25 Jun 2026 20:25:27 +0100 Subject: [PATCH] fix: keep trailing underscore in expression before close tag An expression ending in an identifier whose last character is `_` (for example `<%= foo_%>`) was parsed as `foo` plus a `_%>` whitespace-slurping close, so it read the wrong variable and stripped the following whitespace. The spaced form `<%= foo_ %>` already keeps `foo_`, so the no-space form diverged from it. `_%>` is only a close tag when its leading `_` is a delimiter prefix, not when it continues a JS identifier. Guard both places that treat `_%>` as a close (the tokenizer regex and the whitespace pre-pass) with a `(?` now matches `foo_ %>` while the documented `<%_ ... _%>` and `-%>` slurp behaviors are unchanged. --- lib/esm/ejs.js | 14 +++++++++++++- test/ejs.js | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/esm/ejs.js b/lib/esm/ejs.js index c32cdee9..9e57476a 100644 --- a/lib/esm/ejs.js +++ b/lib/esm/ejs.js @@ -562,6 +562,15 @@ Template.prototype = { str = str.replace(/%/g, delim) .replace(//g, close); + // The `_%>` whitespace-slurping close is only a close tag when its leading + // `_` is not part of a preceding JS identifier. Guard it with a lookbehind + // so `<%= foo_%>` reads the expression `foo_` instead of splitting it into + // `foo` + `_%>` (the spaced form `<%= foo_ %>` already keeps `foo_`). + // Added after delimiter substitution so the lookbehind syntax is not + // rewritten by the `<`/`>` replacements above. `delim`/`close` are already + // regex-escaped, so the close substring is reused as-is. + let slurpClose = '_' + delim + close; + str = str.replace(slurpClose, '(?`) is not mistaken for a `_%>` slurp close, + // which keeps its surrounding whitespace just like the spaced `<%= foo_ %>`. this.templateText = this.templateText.replace(new RegExp('[ \\t]*' + openWhitespaceSlurpTag, 'gm'), openWhitespaceSlurpReplacement) - .replace(new RegExp(closeWhitespaceSlurpTag + '[ \\t]*', 'gm'), closeWhitespaceSlurpReplacement); + .replace(new RegExp('(?', function () { assert.equal(ejs.render(fixture('space-and-tab-slurp.ejs'), {users: users}), fixture('space-and-tab-slurp.html')); }); + + test('does not truncate a trailing underscore in an expression', function () { + var data = {foo: 'WRONG', foo_: 'RIGHT'}; + // `foo_%>` must read `foo_` and keep its whitespace, like `foo_ %>` does. + assert.equal(ejs.render('<%= foo_ %> END', data), 'RIGHT END'); + assert.equal(ejs.render('<%= foo_%> END', data), 'RIGHT END'); + }); + + test('still treats a real `_%>` as a whitespace-slurping close', function () { + assert.equal(ejs.render('

<%_ var x = 1; _%> \n<%= x %>

'), '

1

'); + assert.equal(ejs.render('<%= 1 _%> END'), '1END'); + }); }); suite('single quotes', function () {