From 69cb00eb61d306c6ef3a3f0593d87d2def5d3a8f Mon Sep 17 00:00:00 2001 From: Wind Li Date: Wed, 11 Feb 2026 22:25:49 +0800 Subject: [PATCH 1/5] 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); + } + } +} From 7533ad8da465ceb8cbe83641033902f13fa7e514 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Thu, 12 Feb 2026 13:55:39 +0800 Subject: [PATCH 2/5] ignore files --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c9019680..9900f104 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,10 @@ jacoco.exec out tmp -tmp/spotless \ No newline at end of file +tmp/spotless +.claude.json +.claudeingore +.vscode/ +CLAUDE.md +logs/ + From 72d8ff6da23d6d6376b0e50453b0225a2048fd1a Mon Sep 17 00:00:00 2001 From: Wind Li Date: Wed, 18 Feb 2026 10:47:52 +0800 Subject: [PATCH 3/5] feat(handlebars): support nested property paths in partial context Add support for complex nested property paths in partial context parameter. When preserveParentContext is enabled, partials can now use paths like `../../target/nested/value` to access nested properties from ancestor contexts. Changes: - Partial.java: Add PathCompiler-based property resolution for remaining context after navigating up parent chain (e.g., `../../target/nested/value`) This is consistent with justdb's handlebars route optimization feature, but uses the standard Handlebars syntax: `{{> partial ../../path/to/property}}` instead of justdb's extended syntax: `{{> ../../path/to/property}}`. Test results: - All 20 PartialContextModeTest tests pass - Backward compatibility maintained --- .../jknack/handlebars/internal/Partial.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 5b4eb510..be2f8d0f 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 @@ -237,6 +237,28 @@ protected void merge(final Context context, final Writer writer) throws IOExcept template.apply(currentContext.parent(), writer); return; } + + // Handle paths like ../../xxx or ../../xxx/yyy + // Navigate to the specified child from the navigated parent context + // Use Handlebars' built-in path resolution mechanism via context.get() + if (currentContext != null && !"".equals(remainingContext)) { + // Compile the remaining path (e.g., "xxx" or "xxx/yyy") using PathCompiler + // This leverages Handlebars' existing value resolution mechanism + List compiledPath = PathCompiler.compile(remainingContext); + Object resolvedValue = currentContext.get(compiledPath); + + // Create a new context with the resolved value as the model + // This preserves the parent-child relationship correctly + Context targetContext = Context.newContext(currentContext, resolvedValue); + + // Apply template with the resolved context + context.data(Context.CALLEE, this); + Map hash = hash(context); + override(context, hash, OVERRIDE_PROPERTIES); + template.apply(targetContext, writer); + context.data(Context.CALLEE, callee); + return; + } } // Traditional mode: create new PartialCtx (default behavior for backward compatibility) From 9d9596111442c5082652c6b18c4a92d11b28a435 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Wed, 18 Feb 2026 12:23:26 +0800 Subject: [PATCH 4/5] feat(handlebars): add nested property path support for partial context navigation Enhanced preserveParentContext mode to support nested property paths in partial context parameters. Changes: - Updated Partial.java documentation to reflect support for ../../xxx/yyy paths - Added 3 new test cases for nested property path resolution: - testRender_PreserveMode_NestedPropertyPath: tests ../../target/nested/value navigation - testRender_PreserveMode_SinglePropertyPath: tests ../targetValue navigation - testRender_PreserveMode_NestedPropertyPath_MissingProperty: tests missing property handling The implementation uses Handlebars' PathCompiler for property resolution, maintaining compatibility with existing behavior while enabling more complex context navigation scenarios. All 23 tests pass successfully. --- .../jknack/handlebars/internal/Partial.java | 3 +- .../handlebars/PartialContextModeTest.java | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) 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 be2f8d0f..cc9a7971 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 @@ -205,7 +205,8 @@ 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) + // Supports: this, .., ../.., ../../xxx, ../../xxx/yyy (nested property paths) + // Uses PathCompiler to resolve property paths like ../../target/nested/value if (handlebars.preserveParentContext() && ("this".equals(this.scontext) || this.scontext.startsWith(".."))) { diff --git a/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java index 9fad18d5..8de79560 100644 --- a/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java +++ b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.Test; @@ -419,5 +421,102 @@ public void testRender_ThisContext_DirectContext() throws IOException { assertEquals("Child-Root", result); } + + /** + * Test rendering with nested property path in context parameter. + * + *

When preserveParentContext is enabled and using explicit context chain, + * {{> partial ../../target/nested/value}} should: + *

    + *
  1. Navigate up two context levels (via ../..)
  2. + *
  3. Access 'target.nested.value' property path at that level
  4. + *
  5. Render the partial with that value as context
  6. + *
+ * + *

This tests the new nested property path resolution feature using PathCompiler. + * Note: This test manually creates the context chain to ensure proper parent-child relationships. + */ + @Test + public void testRender_PreserveMode_NestedPropertyPath() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("value", "{{this}}"); // Partial renders current context value + hbs.with(loader); + + // Create a proper context chain: root -> level1 -> level2 + // root.target.nested.value = "NESTED_VALUE" + Map nested = $("value", "NESTED_VALUE"); + Map target = new HashMap<>(); + target.put("nested", nested); + + // Build context chain: create root first, then children + // When template is applied to level2, parent() navigates up to level1, then root + Context root = Context.newContext($("name", "root", "target", target)); + Context level1 = Context.newContext(root, $("name", "level1")); + Context level2 = Context.newContext(level1, $("name", "level2")); + + // Template: "{{> value ../../target/nested/value}}" + // - ../../ navigates up 2 levels to root + // - target/nested/value resolves to root.target.nested.value + // - partial "value" renders {{this}} which should render "NESTED_VALUE" + Template template = hbs.compileInline("{{> value ../../target/nested/value}}"); + String result = template.apply(level2); + + assertEquals("NESTED_VALUE", result); + } + + /** + * Test rendering with single-level property path in context parameter. + * + *

Tests the basic case of accessing a single property after parent navigation. + */ + @Test + public void testRender_PreserveMode_SinglePropertyPath() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("value", "{{this}}"); + hbs.with(loader); + + // Create context chain: parent -> child + // parent.targetValue = "TARGET" + Context parent = Context.newContext($("name", "parent", "targetValue", "TARGET")); + Context child = Context.newContext(parent, $("name", "child")); + + // Template: "{{> value ../targetValue}}" + // - .. navigates up 1 level to parent + // - targetValue resolves to parent.targetValue + Template template = hbs.compileInline("{{> value ../targetValue}}"); + String result = template.apply(child); + + assertEquals("TARGET", result); + } + + /** + * Test that partial with nested property path handles missing properties gracefully. + * + *

When a property in the path doesn't exist, the behavior should be consistent + * with Handlebars' standard missing property handling (renders empty string). + */ + @Test + public void testRender_PreserveMode_NestedPropertyPath_MissingProperty() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("value", "{{this}}"); + hbs.with(loader); + + // Create context chain: parent -> child + // Neither parent nor child has the target property + Context parent = Context.newContext($("name", "parent")); + Context child = Context.newContext(parent, $("name", "child")); + + // Template: "{{> value ../missingProperty}}" + // - .. navigates up 1 level to parent + // - missingProperty resolves to null (not found) + Template template = hbs.compileInline("{{> value ../missingProperty}}"); + String result = template.apply(child); + + // When property is missing, should render empty string + assertEquals("", result); + } } } From f689e5ea6a3260bfddc4c96544aec6ead9e4a148 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Wed, 18 Feb 2026 14:04:18 +0800 Subject: [PATCH 5/5] refactor(handlebars): improve code quality based on code review Address code review suggestions to enhance robustness and documentation: Changes: - Add explicit comment explaining null value handling for resolvedValue - Improve documentation clarity with example of path resolution - Add test for trailing slash edge case (../foo/) - Add test for navigation beyond available parent levels (../../../) The trailing slash test verifies that PathCompiler correctly handles paths with trailing slashes. The navigation beyond parents test ensures graceful degradation when navigating up more levels than available in the context chain. All 25 tests pass successfully (6 base + 19 preserve mode tests). --- .../jknack/handlebars/internal/Partial.java | 5 +- .../handlebars/PartialContextModeTest.java | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) 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 cc9a7971..e4635736 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 @@ -240,8 +240,8 @@ protected void merge(final Context context, final Writer writer) throws IOExcept } // Handle paths like ../../xxx or ../../xxx/yyy - // Navigate to the specified child from the navigated parent context - // Use Handlebars' built-in path resolution mechanism via context.get() + // After navigating up parent chain, remaining path is resolved using PathCompiler + // Example: "../../target/nested/value" -> navigate up 2 levels, resolve "target/nested/value" if (currentContext != null && !"".equals(remainingContext)) { // Compile the remaining path (e.g., "xxx" or "xxx/yyy") using PathCompiler // This leverages Handlebars' existing value resolution mechanism @@ -250,6 +250,7 @@ protected void merge(final Context context, final Writer writer) throws IOExcept // Create a new context with the resolved value as the model // This preserves the parent-child relationship correctly + // If resolvedValue is null, the template will render empty string (Handlebars standard behavior) Context targetContext = Context.newContext(currentContext, resolvedValue); // Apply template with the resolved context diff --git a/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java index 8de79560..63aac526 100644 --- a/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java +++ b/handlebars/src/test/java/com/github/jknack/handlebars/PartialContextModeTest.java @@ -518,5 +518,60 @@ public void testRender_PreserveMode_NestedPropertyPath_MissingProperty() throws // When property is missing, should render empty string assertEquals("", result); } + + /** + * Test rendering with path containing trailing slash. + * + *

Edge case test to ensure PathCompiler handles trailing slash gracefully. + * The path "../../foo/" should navigate up parent levels and attempt to resolve "foo/". + */ + @Test + public void testRender_PreserveMode_TrailingSlash() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("value", "{{this}}"); + hbs.with(loader); + + // Create context chain: parent -> child + // parent.foo = "FOO_VALUE" + Context parent = Context.newContext($("name", "parent", "foo", "FOO_VALUE")); + Context child = Context.newContext(parent, $("name", "child")); + + // Template: "{{> value ../foo/}}" + // - .. navigates up 1 level to parent + // - foo/ attempts to resolve with trailing slash + Template template = hbs.compileInline("{{> value ../foo/}}"); + String result = template.apply(child); + + // PathCompiler should handle trailing slash and resolve the property + assertEquals("FOO_VALUE", result); + } + + /** + * Test navigation beyond available parent levels. + * + *

Edge case test to ensure graceful handling when navigating up more levels + * than available in the context chain. + */ + @Test + public void testRender_PreserveMode_NavigationBeyondParents() throws IOException { + Handlebars hbs = new Handlebars().preserveParentContext(true); + MapTemplateLoader loader = new MapTemplateLoader(); + loader.define("value", "{{this}}"); + hbs.with(loader); + + // Create context chain with only 2 levels: child -> parent + Context parent = Context.newContext($("name", "parent", "value", "PARENT_VALUE")); + Context child = Context.newContext(parent, $("name", "child")); + + // Template: "{{> value ../../../value}}" + // - Attempts to navigate up 3 levels but only 2 are available + // - Should gracefully handle by navigating to root (null parent stops navigation) + Template template = hbs.compileInline("{{> value ../../../value}}"); + String result = template.apply(child); + + // When navigation exceeds parent chain, should render empty string gracefully + assertEquals("", result); + } } }