Skip to content

Commit ed8524b

Browse files
committed
fix: make n:context nodes more robbust but document limitations also
1 parent 054d12a commit ed8524b

File tree

5 files changed

+147
-12
lines changed

5 files changed

+147
-12
lines changed

docs/extensions/frontend.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,45 @@ The `{name}` placeholder will be replaced with the component name specified in t
6464

6565
## Usage
6666

67+
### Limitations
68+
69+
**JavaScript Object Literals in Attributes**
70+
71+
Latte's parser treats `{` as an expression delimiter, making it incompatible with JavaScript object literal syntax in attributes:
72+
73+
```latte
74+
{* ❌ This does NOT work - Latte parses { as expression start *}
75+
<div x-data="{ count: {$value}, increment() { ... } }">
76+
```
77+
78+
**Solutions:**
79+
80+
1. **Use `n:data-alpine` for data passing** (recommended):
81+
```latte
82+
{* ✅ Works - serializes PHP to JSON *}
83+
<div n:data-alpine="['count' => $value]">
84+
<button @click="count++">Increment</button>
85+
</div>
86+
```
87+
88+
2. **Use wrapper element** when mixing JavaScript and Latte expressions:
89+
```latte
90+
{* ✅ Works - x-data on wrapper, n:context on form *}
91+
<div x-data="{ count: 0, increment() { this.count++ } }">
92+
<form n:context="$entity">
93+
<button type="button" @click="increment()">Count: <span x-text="count"></span></button>
94+
</form>
95+
</div>
96+
```
97+
98+
3. **Use standard form syntax** without `n:context`:
99+
```latte
100+
{* ✅ Works - manual Form helper call *}
101+
{Form create $entity, ['x-data' => '{ count: ' . $value . ' }']}
102+
<button type="button" @click="count++">Increment</button>
103+
{/Form create}
104+
```
105+
67106
### Basic Data Attributes
68107

69108
#### Generic Data Attribute

src/Latte/Nodes/AttributeParserTrait.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
1818
use Latte\Compiler\Nodes\PrintNode;
1919
use Latte\Compiler\Nodes\TextNode;
20+
use Latte\Compiler\PrintContext;
2021
use LatteView\Extension\Frontend\Nodes\DataSerializationNode;
2122
use LatteView\Extension\Frontend\Serializers\UniversalSerializer;
2223

@@ -25,7 +26,7 @@ trait AttributeParserTrait
2526
/**
2627
* Get the attributes node.
2728
*/
28-
protected function getAttributesNode(?ElementNode $el): ArrayNode
29+
protected function getAttributesNode(?ElementNode $el, ?PrintContext $context = null): ArrayNode
2930
{
3031
if (!$el instanceof ElementNode) {
3132
return new ArrayNode([]);
@@ -54,7 +55,7 @@ protected function getAttributesNode(?ElementNode $el): ArrayNode
5455
$val = $el->getAttribute($name);
5556
$nameNode = new StringNode($name);
5657

57-
$valueNode = $this->parseAttributeValue($val);
58+
$valueNode = $this->parseAttributeValue($val, $context);
5859
if ($valueNode !== null) {
5960
$items[] = new ArrayItemNode($valueNode, $nameNode);
6061
}
@@ -76,8 +77,9 @@ protected function getAttributesNode(?ElementNode $el): ArrayNode
7677

7778
/**
7879
* Parse an attribute value into an ExpressionNode.
80+
* For complex values (FragmentNode with mixed content), returns null to preserve them as-is.
7981
*/
80-
protected function parseAttributeValue(mixed $val): ?ExpressionNode
82+
protected function parseAttributeValue(mixed $val, ?PrintContext $context = null): ?ExpressionNode
8183
{
8284
// Handle PrintNode (expression like {$var})
8385
if ($val instanceof PrintNode) {
@@ -90,6 +92,13 @@ protected function parseAttributeValue(mixed $val): ?ExpressionNode
9092
return $val->children[0]->expression;
9193
}
9294

95+
// Handle FragmentNode with mixed content (text + expressions)
96+
// This is common for attributes like x-data="{ prop: {$value}, ... }"
97+
// We return null so these attributes are preserved as-is on the element
98+
if ($val instanceof FragmentNode && count($val->children) > 1) {
99+
return null;
100+
}
101+
93102
// Handle AreaNode that might contain a PrintNode
94103
if ($val instanceof AreaNode) {
95104
// Try to find a PrintNode in the tree
@@ -107,6 +116,20 @@ protected function parseAttributeValue(mixed $val): ?ExpressionNode
107116
return null;
108117
}
109118

119+
/**
120+
* Check if an attribute should be skipped from CakePHP form argument processing.
121+
* These attributes should render as-is in the HTML output.
122+
*
123+
* @param string $name The attribute name
124+
* @return bool True if the attribute should be skipped
125+
*/
126+
protected function shouldSkipAttribute(string $name): bool
127+
{
128+
// This method is kept for potential future use but currently not used
129+
// since we handle all attributes through parseAttributeValue
130+
return false;
131+
}
132+
110133
/**
111134
* Find a PrintNode in an AreaNode tree.
112135
*/

src/Latte/Nodes/Form/FormNContextNode.php

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@
44
namespace LatteView\Latte\Nodes\Form;
55

66
use Generator;
7+
use Latte\Compiler\NodeHelpers;
78
use Latte\Compiler\Nodes\AreaNode;
89
use Latte\Compiler\Nodes\AuxiliaryNode;
910
use Latte\Compiler\Nodes\FragmentNode;
11+
use Latte\Compiler\Nodes\Html\AttributeNode;
12+
use Latte\Compiler\Nodes\Html\ElementNode;
13+
use Latte\Compiler\Nodes\Html\ExpressionAttributeNode;
14+
use Latte\Compiler\Nodes\Php\ArgumentNode;
15+
use Latte\Compiler\Nodes\Php\ArrayItemNode;
1016
use Latte\Compiler\Nodes\Php\Expression\ArrayNode;
17+
use Latte\Compiler\Nodes\Php\Expression\FunctionCallNode;
1118
use Latte\Compiler\Nodes\Php\ExpressionNode;
19+
use Latte\Compiler\Nodes\Php\NameNode;
20+
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
1221
use Latte\Compiler\Nodes\StatementNode;
1322
use Latte\Compiler\PrintContext;
1423
use Latte\Compiler\Tag;
24+
use LatteView\Extension\Frontend\Nodes\DataSerializationNode;
25+
use LatteView\Extension\Frontend\Serializers\UniversalSerializer;
1526
use LatteView\Latte\Nodes\AttributeParserTrait;
1627

1728
/**
@@ -52,7 +63,66 @@ public static function create(Tag $tag): Generator
5263
protected function init(Tag $tag): void
5364
{
5465
$el = $tag->htmlElement;
55-
$attributes = $this->getAttributesNode($el);
66+
67+
if (!$el instanceof ElementNode) {
68+
return;
69+
}
70+
71+
// Create a temporary context for attribute compilation
72+
$tempContext = new PrintContext();
73+
74+
// Process attributes and separate them into:
75+
// 1. Attributes that can be passed to Form->create() (parsed successfully)
76+
// 2. Attributes that should remain on the element (complex/unparseable)
77+
$preservedAttributeNodes = [];
78+
$formAttributes = []; // ArrayItemNode[]
79+
80+
foreach ($el->attributes->children as $child) {
81+
// Handle DataSerializationNode (e.g., n:data-alpine="$data")
82+
if ($child instanceof DataSerializationNode) {
83+
$attrName = $child->getPublicAttributeName();
84+
$nameNode = new StringNode($attrName);
85+
86+
// Create function call: UniversalSerializer::serialize($data)
87+
$valueNode = new FunctionCallNode(
88+
new NameNode(UniversalSerializer::class . '::serialize'),
89+
[new ArgumentNode($child->getDataExpression(), false, false, null, $child->position)],
90+
);
91+
92+
$formAttributes[] = new ArrayItemNode($valueNode, $nameNode);
93+
continue;
94+
}
95+
96+
// Handle standard AttributeNode (e.g., method="get", hx-post="{$path}")
97+
if ($child instanceof AttributeNode) {
98+
$nameText = NodeHelpers::toText($child->name);
99+
if ($nameText !== null) {
100+
$val = $el->getAttribute($nameText);
101+
$parsed = $this->parseAttributeValue($val, $tempContext);
102+
103+
if (!$parsed instanceof ExpressionNode) {
104+
// Complex attribute (e.g., x-data with JavaScript) - preserve on element
105+
$preservedAttributeNodes[] = $child;
106+
} else {
107+
// Simple attribute - add to Form->create() args
108+
$nameNode = new StringNode($nameText);
109+
$formAttributes[] = new ArrayItemNode($parsed, $nameNode);
110+
}
111+
}
112+
113+
continue;
114+
}
115+
116+
// Handle ExpressionAttributeNode (e.g., url="{['_name' => 'display']}")
117+
if ($child instanceof ExpressionAttributeNode) {
118+
$nameNode = new StringNode($child->name);
119+
$formAttributes[] = new ArrayItemNode($child->value, $nameNode);
120+
continue;
121+
}
122+
}
123+
124+
// Build the attributes array for Form->create()
125+
$attributes = new ArrayNode($formAttributes);
56126

57127
$el->dynamicTag = new AuxiliaryNode(fn(PrintContext $context): string => $context->format(
58128
<<<'XX'
@@ -65,7 +135,8 @@ protected function init(Tag $tag): void
65135
$this->position,
66136
));
67137

68-
$el->attributes = new FragmentNode();
138+
// Preserve complex attributes on the form element
139+
$el->attributes = new FragmentNode($preservedAttributeNodes);
69140

70141
// @phpstan-ignore-next-line
71142
$el->content = new FragmentNode([

tests/TestCase/Extension/Frontend/FrontendIntegrationTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ public function testJavaScriptModeFallback(): void
237237

238238
public function testFormWithAlpineIntegration(): void
239239
{
240-
// Test that x-data attributes work correctly with form n:context
240+
// Test that x-data attributes work correctly with form n:context using n:data-alpine
241241
$action = null;
242242
$isActive = false;
243243
$items = [1, 2, 3];

tests/TestCase/Latte/Nodes/AttributeParserTraitTest.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,17 @@ public function testParseAttributeValueWithFragmentContainingSinglePrintNode():
189189

190190
public function testParseAttributeValueWithFragmentContainingMultipleChildren(): void
191191
{
192-
// Fragment with multiple children - should use findPrintNode
192+
// Fragment with multiple children - returns null to preserve as-is on element
193+
// (e.g., x-data="{ prop: {$value}, func() {...} }")
193194
$variable = new VariableNode('bar');
194195
$printNode = $this->createPrintNode($variable);
195196
$textNode = new TextNode('prefix');
196197
$fragment = new FragmentNode([$textNode, $printNode]);
197198

198199
$result = $this->parser->parseAttributeValuePublic($fragment);
199200

200-
$this->assertInstanceOf(VariableNode::class, $result);
201-
$this->assertEquals('bar', $result->name);
201+
// Should return null for complex fragments (mixed content)
202+
$this->assertNull($result);
202203
}
203204

204205
public function testParseAttributeValueWithString(): void
@@ -284,15 +285,16 @@ public function testFindPrintNodeWithTextNode(): void
284285

285286
public function testParseAttributeValueWithAreaNodeContainingPrintNode(): void
286287
{
287-
// AreaNode (via FragmentNode) with a PrintNode inside
288+
// AreaNode (via FragmentNode) with multiple children including a PrintNode
289+
// Returns null since it's mixed content that should be preserved on the element
288290
$variable = new VariableNode('area');
289291
$printNode = $this->createPrintNode($variable);
290292
$fragment = new FragmentNode([new TextNode('before'), $printNode, new TextNode('after')]);
291293

292294
$result = $this->parser->parseAttributeValuePublic($fragment);
293295

294-
$this->assertInstanceOf(VariableNode::class, $result);
295-
$this->assertEquals('area', $result->name);
296+
// Should return null for mixed content fragments
297+
$this->assertNull($result);
296298
}
297299

298300
public function testParseAttributeValueWithAreaNodeWithoutPrintNode(): void

0 commit comments

Comments
 (0)