Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Fluid.Tests/GoldendLiquidTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public class GoldenLiquidTests
["id:filters_date_undefined_argument"] = "test expects error but Shopify parses date",

["tag:snippet tag"] = "snippet tag not implemented",
["tag:ifchanged tag"] = "ifchanged tag not implemented",
["tag:strict2"] = "Fluid uses lenient parsing mode; strict2 mode not implemented",

["id:tags_liquid_single_line_comment_tag"] = "Skipping: `{% liquid %}` comment/endcomment special-case parsing not implemented",
Expand Down
130 changes: 130 additions & 0 deletions Fluid.Tests/IfChangedStatementTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Fluid.Ast;
using Fluid.Values;
using Xunit;

namespace Fluid.Tests
{
public class IfChangedStatementTests
{
[Fact]
public async Task IfChangedOutputsOnFirstInvocation()
{
var statement = new IfChangedStatement(
[new OutputStatement(new LiteralExpression(new StringValue("hello")))]
);

var context = new TemplateContext();
var sw = new StringWriter();

await statement.WriteToAsync(sw, HtmlEncoder.Default, context);

Assert.Equal("hello", sw.ToString());
}

[Fact]
public async Task IfChangedDoesNotOutputOnSecondIdenticalInvocation()
{
var statement = new IfChangedStatement(
[new OutputStatement(new LiteralExpression(new StringValue("hello")))]
);

var context = new TemplateContext();
var sw = new StringWriter();

await statement.WriteToAsync(sw, HtmlEncoder.Default, context);
await statement.WriteToAsync(sw, HtmlEncoder.Default, context);

Assert.Equal("hello", sw.ToString());
}

[Fact]
public async Task IfChangedOutputsWhenContentChanges()
{
// We need to use parsed templates to test dynamic content
var parser = new FluidParser();
var template = parser.Parse("{% assign x = 'a' %}{% ifchanged %}{{ x }}{% endifchanged %}{% assign x = 'b' %}{% ifchanged %}{{ x }}{% endifchanged %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

Assert.Equal("ab", result);
}

[Fact]
public async Task IfChangedEmptyBlockOutputsOnce()
{
var parser = new FluidParser();
var template = parser.Parse("{% ifchanged %}{% endifchanged %}{% ifchanged %}{% endifchanged %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

Assert.Equal("", result);
}

[Fact]
public async Task IfChangedWorksWithinForLoop()
{
var parser = new FluidParser();
var template = parser.Parse("{% assign list = '1,1,2,2,3' | split: ',' %}{% for item in list %}{% ifchanged %}{{ item }}{% endifchanged %}{% endfor %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

Assert.Equal("123", result);
}

[Fact]
public async Task IfChangedOutputsDifferentContent()
{
var parser = new FluidParser();
var template = parser.Parse("{% ifchanged %}a{% endifchanged %}{% ifchanged %}b{% endifchanged %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

// Both output because content is different ("a" vs "b")
Assert.Equal("ab", result);
}

[Fact]
public async Task IfChangedRespectsWhitespaceTrimming()
{
var parser = new FluidParser();
var template = parser.Parse("X{%- ifchanged -%}Y{%- endifchanged -%}Z");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

Assert.Equal("XYZ", result);
}

[Fact]
public async Task IfChangedHandlesNilValues()
{
var parser = new FluidParser();
var template = parser.Parse("{% ifchanged %}{{ undefined }}{% endifchanged %}{% ifchanged %}{{ undefined }}{% endifchanged %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

// Both output empty string, but first invocation outputs (empty), second doesn't
Assert.Equal("", result);
}

[Fact]
public async Task IfChangedWithSortedList()
{
var parser = new FluidParser();
var template = parser.Parse("{% assign list = \"1,3,2,1,3,1,2\" | split: \",\" | sort %}{% for item in list -%}{%- ifchanged %} {{ item }}{% endifchanged -%}{%- endfor %}");

var context = new TemplateContext();
var result = await template.RenderAsync(context);

Assert.Equal(" 1 2 3", result);
}
}
}
10 changes: 10 additions & 0 deletions Fluid/Ast/AstRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,16 @@ protected internal override Statement VisitIfStatement(IfStatement ifStatement)
return ifStatement;
}

protected internal override Statement VisitIfChangedStatement(IfChangedStatement ifChangedStatement)
{
if (TryRewriteStatements(ifChangedStatement.Statements, out var newStatements))
{
return new IfChangedStatement(newStatements.ToList());
}

return ifChangedStatement;
}

protected internal override Statement VisitIncludeStatement(IncludeStatement includeStatement)
{
if (TryRewriteExpression(includeStatement.For, out var newFor) |
Expand Down
10 changes: 10 additions & 0 deletions Fluid/Ast/AstVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ protected internal virtual Statement VisitIfStatement(IfStatement ifStatement)
return ifStatement;
}

protected internal virtual Statement VisitIfChangedStatement(IfChangedStatement ifChangedStatement)
{
foreach (var statement in ifChangedStatement.Statements)
{
Visit(statement);
}

return ifChangedStatement;
}

protected internal virtual Statement VisitIncludeStatement(IncludeStatement includeStatement)
{
Visit(includeStatement.For);
Expand Down
50 changes: 50 additions & 0 deletions Fluid/Ast/IfChangedStatement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Fluid.Utils;
using System.Text.Encodings.Web;

namespace Fluid.Ast
{
public sealed class IfChangedStatement : TagStatement
{
private const string IfChangedRegisterKey = "$$ifchanged$$";

public IfChangedStatement(IReadOnlyList<Statement> statements) : base(statements)
{
}

public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context)
{
context.IncrementSteps();

// Get the previous value (shared across all ifchanged blocks)
context.AmbientValues.TryGetValue(IfChangedRegisterKey, out var previousValueObj);
var previousValue = previousValueObj as string;

// Render inner statements to a buffer
using var captureOutput = new BufferFluidOutput();
var completion = Completion.Normal;

for (var i = 0; i < Statements.Count; i++)
{
completion = await Statements[i].WriteToAsync(captureOutput, encoder, context);

if (completion != Completion.Normal)
{
break;
}
}

var currentValue = captureOutput.ToString();

// Output only if content has changed from the last ifchanged output
if (!string.Equals(previousValue, currentValue, StringComparison.Ordinal))
{
context.AmbientValues[IfChangedRegisterKey] = currentValue;
output.Write(currentValue);
}

return completion;
}

protected internal override Statement Accept(AstVisitor visitor) => visitor.VisitIfChangedStatement(this);
}
}
9 changes: 9 additions & 0 deletions Fluid/FluidParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,14 @@ public FluidParser(FluidParserOptions parserOptions)
;
CycleTag.Name = "CycleTag";

var IfChangedTag = TagEnd
.And(AnyTagsList)
.AndSkip(CreateTag("endifchanged").ElseError($"'{{% endifchanged %}}' was expected"))
.Then<Statement>(x => new IfChangedStatement(x.Item2))
.ElseError("Invalid 'ifchanged' tag")
;
IfChangedTag.Name = "IfChangedTag";

var DecrementTag = ZeroOrOne(VariableSignature).AndSkip(TagEnd)
.Then<Statement>(x => new DecrementStatement(x))
.ElseError("Invalid 'decrement' tag")
Expand Down Expand Up @@ -622,6 +630,7 @@ public FluidParser(FluidParserOptions parserOptions)
RegisteredTags["capture"] = CaptureTag;
RegisteredTags["cycle"] = CycleTag;
RegisteredTags["decrement"] = DecrementTag;
RegisteredTags["ifchanged"] = IfChangedTag;
RegisteredTags["include"] = IncludeTag;
RegisteredTags["render"] = RenderTag;
RegisteredTags["increment"] = IncrementTag;
Expand Down