From 69cb00eb61d306c6ef3a3f0593d87d2def5d3a8f Mon Sep 17 00:00:00 2001 From: Wind Li Date: Wed, 11 Feb 2026 22:25:49 +0800 Subject: [PATCH] Add preserveParentContext configuration for partial context handling Add a new configuration option `preserveParentContext` that allows partials to maintain the original parent context chain instead of creating a new PartialCtx wrapper. When enabled: - {{> partial this}} uses current context directly without creating new PartialCtx - {{> partial ..}} navigates up parent chain to access parent context - {{> partial ../..}} navigates up multiple parent levels - {{../property}} in partial can access original parent properties Default is false for backward compatibility. Files modified: - Handlebars.java: Add preserveParentContext field, getter, setter, fluent API - Partial.java: Add conditional logic for context navigation - PartialContextModeTest.java: Add 20 comprehensive tests - README.md: Add documentation about the new feature Test results: 999 tests run, 0 failures, 0 errors, 3 skipped --- README.md | 21 + .../github/jknack/handlebars/Handlebars.java | 53 +++ .../jknack/handlebars/internal/Partial.java | 37 ++ .../handlebars/PartialContextModeTest.java | 423 ++++++++++++++++++ 4 files changed, 534 insertions(+) create mode 100644 handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java diff --git a/README.md b/README.md index 2d3a99bc..b25af058 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,27 @@ Hello edgar! The Mustache Spec has some rules for removing spaces and new lines. This feature is disabled by default. You can turn this on by setting the: ```Handlebars.prettyPrint(true)```. +### Preserve Parent Context in Partials + By default, Handlebars.java creates a new partial context when invoking a partial, which means the `{{..}}` operator in a partial references the partial call site rather than the original parent scope. + + You can change this behavior by setting: ```Handlebars.preserveParentContext(true)```. + + When enabled, partials like `{{> partial this}}` or `{{> partial ..}}` will preserve the parent context chain, allowing `{{..}}` inside partials to reference the original parent scope. + + Example: + +```java +Handlebars handlebars = new Handlebars().preserveParentContext(true); +``` + + When `preserveParentContext` is enabled: + * `{{> partial}}` uses the current context directly + * `{{> partial this}}` uses the current context directly + * `{{> partial ..}}` navigates up one parent level + * `{{> partial ../..}}` navigates up multiple parent levels + + Default is: `false` (for backward compatibility). + # Modules diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/Handlebars.java b/handlebars/src/main/java/com/github/jknack/handlebars/Handlebars.java index 48dd8c5b..42142432 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/Handlebars.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/Handlebars.java @@ -366,6 +366,26 @@ private static void sneakyThrow0(final Throwable x) throws */ private boolean preEvaluatePartialBlocks = true; + /** + * If true, preserves the parent context when invoking partials without creating a new PartialCtx. + * This allows {{..}} in partials to reference the original parent scope. + * + *

When enabled: + *

+ * + *

When disabled (default): Creates a new PartialCtx for each partial (traditional behavior). + * + *

Default: false (for backward compatibility) + * + * @since 4.6.0 + */ + private boolean preserveParentContext = false; + /** Standard charset. */ private Charset charset = StandardCharsets.UTF_8; @@ -1300,6 +1320,39 @@ public Handlebars preEvaluatePartialBlocks(final boolean preEvaluatePartialBlock return this; } + /** + * Get the preserve parent context flag. + * + * @return True if parent context is preserved, false otherwise. + */ + public boolean preserveParentContext() { + return preserveParentContext; + } + + /** + * Set the preserve parent context flag. + * + * @param preserveParentContext True to preserve parent context, false to use traditional behavior. + */ + public void setPreserveParentContext(final boolean preserveParentContext) { + this.preserveParentContext = preserveParentContext; + } + + /** + * Set the preserve parent context flag. + * + *

When enabled, partials like {{> partial this}} or {{> partial ..}} will preserve the parent + * context chain, allowing {{..}} inside partials to reference the original parent scope instead of + * the partial call site. + * + * @param preserveParentContext True to preserve parent context, false to use traditional behavior. + * @return This handlebars object. + */ + public Handlebars preserveParentContext(final boolean preserveParentContext) { + setPreserveParentContext(preserveParentContext); + return this; + } + /** * Return a parser factory. * diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/internal/Partial.java b/handlebars/src/main/java/com/github/jknack/handlebars/internal/Partial.java index c1d78315..5b4eb510 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/internal/Partial.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/internal/Partial.java @@ -203,6 +203,43 @@ protected void merge(final Context context, final Writer writer) throws IOExcept } } } + + // Check if preserveParentContext mode is enabled for pure parent path navigation: + // Supports: this, .., ../.. (does not support mixed paths like ../foo/bar or hash arguments) + if (handlebars.preserveParentContext() + && ("this".equals(this.scontext) || this.scontext.startsWith(".."))) { + + if ("this".equals(this.scontext)) { + // Use current context directly, don't create new context + template.apply(context, writer); + return; + } + + // Handle .. and ../.. paths + Context currentContext = context; + String remainingContext = this.scontext; + + // Navigate up the parent chain for each ../ encountered + while (remainingContext.startsWith("../") && currentContext != null) { + currentContext = currentContext.parent(); + remainingContext = remainingContext.substring(3); + } + + if ("".equals(remainingContext) && currentContext != null) { + // "../" or "../../" etc. - use the navigated context + template.apply(currentContext, writer); + return; + } + + if ("..".equals(remainingContext) && currentContext != null + && currentContext.parent() != null) { + // Single ".." - navigate to parent + template.apply(currentContext.parent(), writer); + return; + } + } + + // Traditional mode: create new PartialCtx (default behavior for backward compatibility) context.data(Context.CALLEE, this); Map hash = hash(context); // HACK: hide/override local attribute with parent version (if any) diff --git a/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java new file mode 100644 index 00000000..9fad18d5 --- /dev/null +++ b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java @@ -0,0 +1,423 @@ +/* + * Handlebars.java: https://github.com/jknack/handlebars.java + * Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 + * Copyright (c) 2012 Edgar Espina + */ +package com.github.jknack.handlebars; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +/** + * Tests for preserveParentContext configuration. + * + *

This test verifies the behavior difference between: + *

+ */ +public class PartialContextModeTest extends AbstractTest { + + /** + * Test default mode preserves traditional behavior. + * + *

In default mode, {{..}} in a partial references the partial call site, + * not the parent of the current context. + */ + @Test + public void testDefaultMode_PreservesBehavior() throws IOException { + Handlebars hbs = new Handlebars(); + + // Verify default configuration is false for backward compatibility + assertEquals(false, hbs.preserveParentContext()); + } + + /** + * Test setting preserveParentContext via fluent API. + */ + @Test + public void testFluentAPI() throws IOException { + Handlebars hbs = new Handlebars(); + + // Default should be false + assertEquals(false, hbs.preserveParentContext()); + + // Set to true via fluent API + hbs.preserveParentContext(true); + assertEquals(true, hbs.preserveParentContext()); + + // Set back to false + hbs.preserveParentContext(false); + assertEquals(false, hbs.preserveParentContext()); + } + + /** + * Test setting preserveParentContext via setter. + */ + @Test + public void testSetter() throws IOException { + Handlebars hbs = new Handlebars(); + + // Default should be false + assertEquals(false, hbs.preserveParentContext()); + + // Set to true via setter + hbs.setPreserveParentContext(true); + assertEquals(true, hbs.preserveParentContext()); + + // Set back to false + hbs.setPreserveParentContext(false); + assertEquals(false, hbs.preserveParentContext()); + } + + /** + * Test backward compatibility - default value should be false. + */ + @Test + public void testBackwardCompatibility_DefaultValue() throws IOException { + Handlebars hbs = new Handlebars(); + + // Default value should be false for backward compatibility + assertEquals(false, hbs.preserveParentContext()); + } + + // ======================================== + // Render tests - actual template rendering + // ======================================== + + /** + * Test rendering with {{> partial this}} in default mode. + * + *

Default mode creates a new PartialCtx, so {{../name}} in partial references the call site. + */ + @Test + public void testRender_DefaultMode_ThisContext() throws IOException { + // In default mode, {{../name}} in partial references the partial call context + shouldCompileToWithPartials( + "{{name}} {{> myPartial this}}", + $("name", "root"), + $("myPartial", "{{../name}}"), + "root root"); + } + + /** + * Test that preserveParentContext=false doesn't affect non-partial context navigation. + * + *

This ensures backward compatibility - normal {{../name}} usage outside partials is unaffected. + */ + @Test + public void testRender_DefaultMode_NormalParentNavigation() throws IOException { + // Normal {{../name}} navigation should still work + shouldCompileTo( + "{{#with child}}{{childValue}} {{../rootValue}}{{/with}}", + $("rootValue", "fromRoot", "child", $("childValue", "fromChild")), + "fromChild fromRoot"); + } + + /** + * Nested test class for preserve mode tests. + * + *

This allows us to override newHandlebars() to return a configured instance. + */ + public static class PreserveModeTest extends AbstractTest { + + @Override + protected Handlebars newHandlebars() { + return new Handlebars().preserveParentContext(true); + } + + /** + * Test rendering with {{> partial}} (implicit this) in preserve mode. + * + *

When preserveParentContext is enabled, {{> partial}} uses current context directly. + */ + @Test + public void testRender_PreserveMode_ImplicitThis() throws IOException { + // {{> partial}} with no context parameter should work like {{> partial this}} + shouldCompileToWithPartials( + "{{name}} {{> myPartial}}", + $("name", "root", "value", "rootValue"), + $("myPartial", "{{name}}"), + "root root"); + } + + /** + * Test rendering with {{> partial this}} in preserve mode. + * + *

When preserveParentContext is enabled, {{> partial this}} uses current context directly. + */ + @Test + public void testRender_PreserveMode_ThisContext() throws IOException { + // {{> partial this}} should use the current context directly + shouldCompileToWithPartials( + "{{name}} {{> myPartial this}}", + $("name", "root", "value", "rootValue"), + $("myPartial", "{{name}}"), + "root root"); + } + + /** + * Test rendering with {{> partial ..}} in preserve mode. + * + *

When preserveParentContext is enabled, {{> partial ..}} should use parent context. + * This verifies that the partial can access parent properties. + */ + @Test + public void testRender_PreserveMode_SingleParent() throws IOException { + // Create a context where root has rootValue and child has childValue + // When inside child context, {{> partial ..}} should access root context + shouldCompileToWithPartials( + "{{rootValue}} {{#with child}}{{childValue}} {{> myPartial ..}}{{/with}}", + $( + "rootValue", "fromRoot", + "child", $("childValue", "fromChild")), + $("myPartial", "{{rootValue}}"), + "fromRoot fromChild fromRoot"); + } + + /** + * Test rendering with {{> partial ../..}} in preserve mode (multi-level parent). + * + *

When preserveParentContext is enabled, {{> partial ../..}} should navigate up two levels. + */ + @Test + public void testRender_PreserveMode_MultiLevelParent() throws IOException { + // Create a triple-nested context structure + // Root -> Level1 -> Level2 + // {{> myPartial ../..}} from Level2 should access Root + shouldCompileToWithPartials( + "{{rootValue}} {{#with level1}}{{#with level2}}{{level2Value}} {{> myPartial ../..}}{{/with}}{{/with}}", + $( + "rootValue", "fromRoot", + "level1", $("level1Value", "fromLevel1"), + "level2", $("level2Value", "fromLevel2")), + $("myPartial", "{{rootValue}}"), + "fromRoot fromLevel2 fromRoot"); + } + + /** + * Test rendering - preserve mode with {{> partial this}}. + * + *

When preserveParentContext is enabled, {{../name}} in partial with 'this' context + * references the original parent. + */ + @Test + public void testRender_PreserveMode_WithThis() throws IOException { + // Create a context where root has a name property at top level + // Inside {{#with root}}, {{../name}} should access the top-level name + shouldCompileToWithPartials( + "{{name}} {{#with root}}{{name}} {{> myPartial this}}{{/with}}", + $( + "name", "rootName", + "root", $("name", "rootValue")), + $("myPartial", "{{../name}}"), + "rootName rootValue rootName"); + } + + /** + * Test rendering with nested partials in preserve mode. + * + *

Verifies that nested partial calls maintain the correct parent chain. + */ + @Test + public void testRender_PreserveMode_NestedPartials() throws IOException { + // myPartial calls anotherPartial + // Both should maintain correct parent context + shouldCompileToWithPartials( + "{{rootValue}} {{> myPartial}}", + $("rootValue", "fromRoot", "nestedValue", "fromNested"), + $( + "myPartial", "{{rootValue}} {{> anotherPartial this}}", + "anotherPartial", "{{nestedValue}}"), + "fromRoot fromRoot fromNested"); + } + + /** + * Test rendering with complex nested structure in preserve mode. + * + *

Verifies correct context navigation through multiple nesting levels. + */ + @Test + public void testRender_PreserveMode_ComplexNesting() throws IOException { + // Root -> outer -> inner + // From inner, use partial with ../.. to access root + shouldCompileToWithPartials( + "{{root}} {{#with outer}}{{outer}} {{#with inner}}{{inner}} {{> myPartial ../..}}{{/with}}{{/with}}", + $( + "root", "ROOT", + "outer", $("outer", "OUTER"), + "inner", $("inner", "INNER")), + $("myPartial", "{{root}}"), + "ROOT OUTER INNER ROOT"); + } + + /** + * Test rendering with partial accessing parent properties directly. + * + *

Verifies that partial can access parent context properties when preserveParentContext is enabled. + */ + @Test + public void testRender_PreserveMode_PartialAccessesParentProperties() throws IOException { + // Partial should be able to access parent context properties + shouldCompileToWithPartials( + "{{title}} {{#with content}}{{body}} {{> myPartial ..}}{{/with}}", + $( + "title", "MyTitle", + "content", $("body", "MyBody"), + "footer", "MyFooter"), + $("myPartial", "{{title}}:{{footer}}"), + "MyTitle MyBody MyTitle:MyFooter"); + } + + /** + * Test rendering with {{> partial ../../}} in preserve mode (three levels up). + * + *

Verifies navigation up three context levels. + */ + @Test + public void testRender_PreserveMode_ThreeLevelParent() throws IOException { + // Create a four-nested context structure + // Top -> Level1 -> Level2 -> Level3 + // {{> myPartial ../../..}} from Level3 should access Top + shouldCompileToWithPartials( + "{{topLevel}} {{#with level1}}{{#with level2}}{{#with level3}}{{level3Value}} {{> myPartial ../../..}}{{/with}}{{/with}}{{/with}}", + $( + "topLevel", "TOP", + "level1", $("level1Value", "fromLevel1"), + "level2", $("level2Value", "fromLevel2"), + "level3", $("level3Value", "fromLevel3")), + $("myPartial", "{{topLevel}}"), + "TOP fromLevel3 TOP"); + } + + /** + * Test that the preserveParentContext setting doesn't affect normal partial behavior. + * + *

Ensures backward compatibility - normal partial invocation with explicit context still works. + */ + @Test + public void testRender_PreserveMode_NormalPartialWithContext() throws IOException { + // Normal partial with explicit context should still work + shouldCompileToWithPartials( + "{{rootName}} {{> myPartial myContext}}", + $("rootName", "rootValue", "myContext", $("name", "myContextValue")), + $("myPartial", "{{name}}"), + "rootValue myContextValue"); + } + + /** + * Test rendering with {{> partial ../..}} and verify partial can access multiple parent levels. + * + *

Verifies navigation up two levels correctly accesses grandparent properties. + */ + @Test + public void testRender_PreserveMode_TwoLevelAccess() throws IOException { + shouldCompileToWithPartials( + "{{grandParent}} {{#with parent}}{{parent}} {{#with child}}{{child}} {{> myPartial ../..}}{{/with}}{{/with}}", + $( + "grandParent", "GRAND", + "parent", $("parent", "PARENT"), + "child", $("child", "CHILD")), + $("myPartial", "{{grandParent}}"), + "GRAND PARENT CHILD GRAND"); + } + + /** + * Test case as requested: Create context with parent-child relationship directly. + * + *

Context structure: + *

+ * + *

When preserveParentContext is enabled, {{> outputParent ..}} should access root context. + */ + @Test + public void testRender_ChildContextAccessRootViaPartial_DirectContext() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("outputParent", "{{name}}"); + hbs.with(loader); + + // Create root context: {"name": "Root"} + Context root = Context.newContext($("name", "Root")); + + // Create child context with root as parent: {"name": "Child"} + Context child = Context.newContext(root, $("name", "Child")); + + // Template: "I am {{name}}, child of {{> outputParent ..}}" + Template template = hbs.compileInline("I am {{name}}, child of {{> outputParent ..}}"); + String result = template.apply(child); + + assertEquals("I am Child, child of Root", result); + } + + /** + * Test case: Multi-level parent navigation with direct context creation. + * + *

Context structure: + *

+ * + *

When preserveParentContext is enabled, {{> myPartial ../..}} should access root context. + */ + @Test + public void testRender_MultiLevelParent_DirectContext() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("myPartial", "{{value}}"); + hbs.with(loader); + + // Create multi-level context chain + Context root = Context.newContext($("value", "ROOT")); + Context level1 = Context.newContext(root, $("value", "L1")); + Context level2 = Context.newContext(level1, $("value", "L2")); + + // Template: "{{value}}-{{> myPartial ../..}}" + Template template = hbs.compileInline("{{value}}-{{> myPartial ../..}}"); + String result = template.apply(level2); + + assertEquals("L2-ROOT", result); + } + + /** + * Test case: Verify {{> partial this}} preserves parent chain with direct context. + * + *

Context structure: + *

+ * + *

Partial template: "{{rootName}}" - should access root context's property. + */ + @Test + public void testRender_ThisContext_DirectContext() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("myPartial", "{{rootName}}"); + hbs.with(loader); + + // Create context chain + Context root = Context.newContext($("rootName", "Root")); + Context child = Context.newContext(root, $("childName", "Child")); + + // Template: "{{childName}}-{{> myPartial this}}" + Template template = hbs.compileInline("{{childName}}-{{> myPartial this}}"); + String result = template.apply(child); + + assertEquals("Child-Root", result); + } + } +}