From 7c6116e8653052df52710e91e770274c7b27be7f Mon Sep 17 00:00:00 2001 From: jasmith-hs Date: Fri, 6 Nov 2026 13:46:51 -0500 Subject: [PATCH] feat(truncatehtml): gate void element preservation behind a feature Adds the PRESERVE_VOID_IN_TRUNCATE_HTML built-in feature. When inactive (the default), truncatehtml removes blank void elements as it did before #1366. When active, void elements before the truncation point are kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../jinjava/features/BuiltInFeatures.java | 1 + .../lib/filter/TruncateHtmlFilter.java | 43 ++++++++++---- .../lib/filter/TruncateHtmlFilterTest.java | 59 ++++++++++++++++--- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java b/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java index 87b34a6b8..fa36521d3 100644 --- a/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java +++ b/src/main/java/com/hubspot/jinjava/features/BuiltInFeatures.java @@ -9,4 +9,5 @@ public interface BuiltInFeatures { "IGNORE_NESTED_INTERPRETATION_PARSE_ERRORS"; String OUTPUT_UNDEFINED_VARIABLES_ERROR = "OUTPUT_UNDEFINED_VARIABLES_ERROR"; String INTEGER_SET_TO_LONG_CONVERSION = "INTEGER_SET_TO_LONG_CONVERSION"; + String PRESERVE_VOID_IN_TRUNCATE_HTML = "PRESERVE_VOID_IN_TRUNCATE_HTML"; } diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java index 2ce2afa4e..afc612d90 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java @@ -3,6 +3,7 @@ import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.features.BuiltInFeatures; import com.hubspot.jinjava.interpret.InvalidArgumentException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.interpret.TemplateError; @@ -144,11 +145,20 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) killwords = BooleanUtils.toBoolean(args[2]); } + boolean preserveVoidElements = interpreter + .getConfig() + .getFeatures() + .isActive( + BuiltInFeatures.PRESERVE_VOID_IN_TRUNCATE_HTML, + interpreter.getContext() + ); + Document dom = Jsoup.parseBodyFragment((String) var); ContentTruncatingNodeVisitor visitor = new ContentTruncatingNodeVisitor( length, ends, - killwords + killwords, + preserveVoidElements ); dom.select("body").traverse(visitor); dom.select("." + DELETE_MARKER_CLASS).remove(); @@ -165,13 +175,20 @@ private static class ContentTruncatingNodeVisitor implements NodeVisitor { private int textLen; private final String ending; private final boolean killwords; + private final boolean preserveVoidElements; private final Deque keptVoidElementsAtEntry = new ArrayDeque<>(); private int keptVoidElements; - ContentTruncatingNodeVisitor(int maxTextLen, String ending, boolean killwords) { + ContentTruncatingNodeVisitor( + int maxTextLen, + String ending, + boolean killwords, + boolean preserveVoidElements + ) { this.maxTextLen = maxTextLen; this.ending = ending; this.killwords = killwords; + this.preserveVoidElements = preserveVoidElements; } @Override @@ -193,7 +210,7 @@ public void head(Node node, int depth) { } else { textLen += textContent.length(); } - } else if (node instanceof Element el) { + } else if (preserveVoidElements && node instanceof Element el) { keptVoidElementsAtEntry.push(keptVoidElements); if (isVoidElement(el)) { if (textLen >= maxTextLen) { @@ -207,15 +224,19 @@ public void head(Node node, int depth) { @Override public void tail(Node node, int depth) { - if (node instanceof Element el) { - boolean containsKeptVoidElement = - keptVoidElements > keptVoidElementsAtEntry.pop(); - if ( - !isVoidElement(el) && !containsKeptVoidElement && StringUtils.isBlank(el.text()) - ) { - el.addClass(DELETE_MARKER_CLASS); - } + if (node instanceof Element el && shouldRemoveOnExit(el)) { + el.addClass(DELETE_MARKER_CLASS); + } + } + + private boolean shouldRemoveOnExit(Element el) { + if (!preserveVoidElements) { + return StringUtils.isBlank(el.text()); } + boolean containsKeptVoidElement = keptVoidElements > keptVoidElementsAtEntry.pop(); + return ( + !isVoidElement(el) && !containsKeptVoidElement && StringUtils.isBlank(el.text()) + ); } private static boolean isVoidElement(Element el) { diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java index 55bf267de..ace399084 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java @@ -5,6 +5,12 @@ import com.google.common.collect.ImmutableMap; import com.google.common.io.Resources; import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.features.BuiltInFeatures; +import com.hubspot.jinjava.features.FeatureConfig; +import com.hubspot.jinjava.features.FeatureStrategies; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; import java.io.IOException; import java.nio.charset.StandardCharsets; import org.junit.Before; @@ -13,10 +19,27 @@ public class TruncateHtmlFilterTest extends BaseInterpretingTest { TruncateHtmlFilter filter; + JinjavaInterpreter preserveVoidInterpreter; @Before public void setup() { filter = new TruncateHtmlFilter(); + preserveVoidInterpreter = + new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withFeatureConfig( + FeatureConfig + .newBuilder() + .add( + BuiltInFeatures.PRESERVE_VOID_IN_TRUNCATE_HTML, + FeatureStrategies.ACTIVE + ) + .build() + ) + .build() + ) + .newInterpreter(); } @Test @@ -82,7 +105,7 @@ public void itTakesKwargs() { public void itPreservesVoidElementsWhenContentIsUnderTheLimit() { String result = (String) filter.filter( "

Hey,
Great to hear!

", - interpreter, + preserveVoidInterpreter, "255" ); assertThat(result) @@ -93,7 +116,7 @@ public void itPreservesVoidElementsWhenContentIsUnderTheLimit() { public void itPreservesVoidElementsBeforeTheTruncationPoint() { String result = (String) filter.filter( "

Hello
world, this is long text

", - interpreter, + preserveVoidInterpreter, "10", "...", "true" @@ -105,7 +128,7 @@ public void itPreservesVoidElementsBeforeTheTruncationPoint() { public void itRemovesVoidElementsAfterTheTruncationPoint() { String result = (String) filter.filter( "

Hello world

more
text

", - interpreter, + preserveVoidInterpreter, "5", "...", "true" @@ -117,7 +140,7 @@ public void itRemovesVoidElementsAfterTheTruncationPoint() { public void itPreservesSelfClosingBrTagsBeforeTheTruncationPoint() { String result = (String) filter.filter( "

Hey,
Great to hear!

", - interpreter, + preserveVoidInterpreter, "255" ); assertThat(result).isEqualTo("

Hey,\n
\n Great to hear!

"); @@ -127,7 +150,7 @@ public void itPreservesSelfClosingBrTagsBeforeTheTruncationPoint() { public void itRemovesSelfClosingBrTagsAfterTheTruncationPoint() { String result = (String) filter.filter( "

Hello world
more text


", - interpreter, + preserveVoidInterpreter, "5", "...", "true" @@ -139,7 +162,7 @@ public void itRemovesSelfClosingBrTagsAfterTheTruncationPoint() { public void itPreservesVoidElementsWithExplicitClosingTagsBeforeTheTruncationPoint() { String result = (String) filter.filter( "

Great to hear!

", - interpreter, + preserveVoidInterpreter, "255" ); assertThat(result).isEqualTo("

Great to hear!

"); @@ -149,7 +172,7 @@ public void itPreservesVoidElementsWithExplicitClosingTagsBeforeTheTruncationPoi public void itRemovesVoidElementsWithExplicitClosingTagsAfterTheTruncationPoint() { String result = (String) filter.filter( "

Hello world

", - interpreter, + preserveVoidInterpreter, "5", "...", "true" @@ -157,6 +180,28 @@ public void itRemovesVoidElementsWithExplicitClosingTagsAfterTheTruncationPoint( assertThat(result).isEqualTo("

Hello...

"); } + @Test + public void itRemovesVoidElementsWhenFeatureIsInactive() { + String result = (String) filter.filter( + "

Hey,
Great to hear!

", + interpreter, + "255" + ); + assertThat(result).isEqualTo("

Hey,Great to hear!

"); + } + + @Test + public void itRemovesVoidElementsBeforeTheTruncationPointWhenFeatureIsInactive() { + String result = (String) filter.filter( + "

Hello
world, this is long text

", + interpreter, + "10", + "...", + "true" + ); + assertThat(result).isEqualTo("

Helloworld...

"); + } + private static String fixture(String name) { try { return Resources.toString(Resources.getResource(name), StandardCharsets.UTF_8);