diff --git a/Fluid.Tests/ParserTests.cs b/Fluid.Tests/ParserTests.cs
index 24e5acdf..89e54630 100644
--- a/Fluid.Tests/ParserTests.cs
+++ b/Fluid.Tests/ParserTests.cs
@@ -289,6 +289,7 @@ public void ShouldFailParseInvalidTemplateWithCorrectLineNumber(string source, s
[InlineData("{% unless true %}")]
[InlineData("{% case a %}")]
[InlineData("{% capture myVar %}")]
+ [InlineData("{% paginate myVar by 50 %}")]
public void ShouldFailNotClosedBlock(string source)
{
var result = _parser.TryParse(source, out var template, out var errors);
@@ -303,6 +304,7 @@ public void ShouldFailNotClosedBlock(string source)
[InlineData("{% unless true %} {% endunless %}")]
[InlineData("{% case a %} {% when 'cake' %} blah {% endcase %}")]
[InlineData("{% capture myVar %} capture me! {% endcapture %}")]
+ [InlineData("{% paginate myVar by 50 %} paginate {% endpaginate %}")]
public void ShouldSucceedClosedBlock(string source)
{
var result = _parser.TryParse(source, out var template, out var error);
@@ -414,6 +416,7 @@ public void ShouldRegisterModelType()
[InlineData("{% comment %}")]
[InlineData("{% raw %}")]
[InlineData("{% capture %}")]
+ [InlineData("{% paginate %}")]
public void ShouldThrowParseExceptionMissingTag(string template)
{
@@ -619,7 +622,7 @@ public Task EmptyShouldEqualToNil(string source, string expected)
{
return CheckAsync(source, expected, t => t.SetValue("e", "").SetValue("f", "hello"));
}
-
+
[Theory]
[InlineData("zero == empty", "false")]
[InlineData("empty == zero", "false")]
@@ -642,7 +645,7 @@ public Task BlankShouldComparesToFalse(string source, string expected)
{
return CheckAsync(source, expected, t => t.SetValue("zero", 0).SetValue("one", 1));
}
-
+
[Fact]
public void CycleShouldHandleNumbers()
{
@@ -657,7 +660,7 @@ public void CycleShouldHandleNumbers()
var rendered = template.Render();
Assert.Equal("1
2
3
1
2
3
1
2
3
", rendered);
- }
+ }
[Fact]
public void ShouldAssignWithLogicalExpression()
@@ -921,5 +924,23 @@ public async Task ShouldSupportCompactNotation(string source, string expected)
var result = await template.RenderAsync(context);
Assert.Equal(expected, result);
}
+
+ [Fact]
+ public void ShouldParsePaginateTag()
+ {
+ var statements = Parse("{% paginate list by 10 %}{% endpaginate %}");
+
+ Assert.IsType(statements.ElementAt(0));
+ }
+ [Fact]
+ public void ShouldParsePaginateWithPageSizeTag()
+ {
+ var statements = Parse("{% paginate list by 10 %}{% endpaginate %}");
+
+ Assert.IsType(statements.ElementAt(0));
+
+ var forStatement = statements.ElementAt(0) as PaginateStatement;
+ Assert.True(forStatement.PageSize == 10);
+ }
}
}
diff --git a/Fluid/Ast/PaginateStatement.cs b/Fluid/Ast/PaginateStatement.cs
new file mode 100644
index 00000000..36759f9b
--- /dev/null
+++ b/Fluid/Ast/PaginateStatement.cs
@@ -0,0 +1,249 @@
+using Fluid.Values;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Fluid.Ast
+{
+ public class PaginateStatement : Statement
+ {
+ private readonly Expression _expression;
+ private readonly long _pageSize;
+ private readonly List _statements;
+
+ public PaginateStatement(Expression expression, long pageSize, List statements)
+ {
+ _expression = expression ?? throw new ArgumentNullException(nameof(expression));
+ _pageSize = pageSize;
+ _statements = statements ?? new List();
+ }
+
+ public long PageSize => _pageSize;
+
+ public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context)
+ {
+ var value = await _expression.EvaluateAsync(context);
+ if (value == null || value is not PaginateableValue paginateableValue) return Completion.Normal;
+ context.EnterChildScope();
+ try
+ {
+ paginateableValue.PageSize = (int)PageSize;
+
+ var data = paginateableValue.GetPaginatedData();
+ var paginate = CreatePaginate(paginateableValue, data);
+
+ context.SetValue("paginate", paginate);
+
+ await _statements.RenderStatementsAsync(writer, encoder, context);
+ }
+ finally
+ {
+ context.ReleaseScope();
+ }
+ return Completion.Normal;
+ }
+
+ private PaginateValue CreatePaginate(PaginateableValue value, PaginatedData data)
+ {
+ var ret = new PaginateValue
+ {
+ Items = data.Total,
+ CurrentOffset = (value.CurrentPage - 1) * value.PageSize,
+ CurrentPage = value.CurrentPage,
+ PageSize = value.PageSize,
+ Pages = data.Total / value.PageSize
+ };
+
+ if (data.Total % value.PageSize > 0) ret.Pages++;
+
+ if (ret.Pages <= 1) return ret;
+
+ if (ret.CurrentPage > 1)
+ {
+ ret.Previous = new PartValue
+ {
+ IsLink = true,
+ Title = "«",
+ Url = value.GetUrl(ret.CurrentPage - 1)
+ };
+ }
+
+ if (ret.CurrentPage < ret.Pages)
+ {
+ ret.Next = new PartValue
+ {
+ IsLink = true,
+ Title = "»",
+ Url = value.GetUrl(ret.CurrentPage + 1)
+ };
+ }
+
+ var min = ret.CurrentPage - 2;
+ var max = ret.CurrentPage + 2;
+
+ if (min <= 1) min = 2;
+ if (max >= ret.Pages) max = ret.Pages - 1;
+
+ var last = 0;
+ for (var page = 1; page <= ret.Pages; page++)
+ {
+ var add = false;
+ if (page == 1)
+ {
+ add = true;
+ }
+ else if (page == ret.Pages)
+ {
+ add = true;
+ }
+ else if (page >= min && page <= max)
+ {
+ add = true;
+ }
+
+ if (!add) continue;
+
+ if (last + 1 != page)
+ {
+ ret.Parts.Add(new PartValue
+ {
+ IsLink = false,
+ Title = "…"
+ });
+ }
+
+ last = page;
+
+ var item = new PartValue
+ {
+ Title = page.ToString(),
+ IsLink = page != ret.CurrentPage
+ };
+
+ if (item.IsLink) item.Url = value.GetUrl(page);
+
+ ret.Parts.Add(item);
+ }
+
+ return ret;
+ }
+
+ ///
+ /// https://shopify.dev/api/liquid/objects/part
+ ///
+ internal sealed class PartValue : FluidValue
+ {
+ public bool IsLink { get; set; }
+ public string Title { get; set; }
+ public string Url { get; set; }
+
+ public override FluidValues Type => FluidValues.Dictionary;
+
+ public override bool Equals(FluidValue other)
+ {
+ return false;
+ }
+
+ public override bool ToBooleanValue()
+ {
+ return false;
+ }
+
+ public override decimal ToNumberValue()
+ {
+ return 0;
+ }
+
+ public override object ToObjectValue()
+ {
+ return null;
+ }
+
+ public override string ToStringValue()
+ {
+ return "part";
+ }
+
+ public override ValueTask GetValueAsync(string name, TemplateContext context)
+ {
+ return name switch
+ {
+ "is_link" => new ValueTask(BooleanValue.Create(IsLink)),
+ "title" => new ValueTask(StringValue.Create(Title)),
+ "url" => new ValueTask(StringValue.Create(Url)),
+ _ => new ValueTask(NilValue.Instance),
+ };
+ }
+
+ public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo)
+ {
+ }
+ }
+
+ ///
+ /// https://shopify.dev/api/liquid/objects/paginate
+ ///
+ internal sealed class PaginateValue : FluidValue
+ {
+ public int CurrentOffset { get; set; }
+ public int CurrentPage { get; set; }
+ public int Items { get; set; }
+ public List Parts { get; } = new();
+ public PartValue Previous { get; set; }
+ public PartValue Next { get; set; }
+ public int PageSize { get; set; }
+ public int Pages { get; set; }
+
+ public override FluidValues Type => FluidValues.Dictionary;
+
+ public override bool Equals(FluidValue other)
+ {
+ return false;
+ }
+
+ public override bool ToBooleanValue()
+ {
+ return false;
+ }
+
+ public override decimal ToNumberValue()
+ {
+ return 0;
+ }
+
+ public override object ToObjectValue()
+ {
+ return null;
+ }
+
+ public override string ToStringValue()
+ {
+ return "paginate";
+ }
+
+ public override ValueTask GetValueAsync(string name, TemplateContext context)
+ {
+ return name switch
+ {
+ "current_offset" => new ValueTask(NumberValue.Create(CurrentOffset)),
+ "current_page" => new ValueTask(NumberValue.Create(CurrentPage)),
+ "items" => new ValueTask(NumberValue.Create(Items)),
+ "parts" => new ValueTask(Create(Parts, context.Options)),
+ "previous" => new ValueTask(Previous),
+ "next" => new ValueTask(Next),
+ "page_size" => new ValueTask(NumberValue.Create(PageSize)),
+ "pages" => new ValueTask(NumberValue.Create(Pages)),
+ _ => new ValueTask(NilValue.Instance),
+ };
+ }
+
+ public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo)
+ {
+ }
+ }
+ }
+}
diff --git a/Fluid/Filters/MiscFilters.cs b/Fluid/Filters/MiscFilters.cs
index 59eb80ed..76e65515 100644
--- a/Fluid/Filters/MiscFilters.cs
+++ b/Fluid/Filters/MiscFilters.cs
@@ -1,14 +1,17 @@
-using System;
+using Fluid.Ast;
+using Fluid.Utils;
+using Fluid.Values;
+using System;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using System.Net;
-using System.Text.RegularExpressions;
+using System.Text;
using System.Text.Json;
-using Fluid.Values;
-using TimeZoneConverter;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using System.Text;
-using System.IO;
+using System.Web;
+using TimeZoneConverter;
namespace Fluid.Filters
{
@@ -90,6 +93,8 @@ public static FilterCollection WithMiscFilters(this FilterCollection filters)
filters.AddFilter("sha1", Sha1);
filters.AddFilter("sha256", Sha256);
+ filters.AddFilter("default_pagination", DefaultPagination);
+
return filters;
}
@@ -842,5 +847,77 @@ public static ValueTask Sha256(FluidValue input, FilterArguments arg
return new StringValue(builder.ToString());
}
}
+
+ ///
+ /// https://shopify.dev/api/liquid/filters/additional-filters#default_pagination
+ ///
+ public static ValueTask DefaultPagination(FluidValue input, FilterArguments arguments, TemplateContext context)
+ {
+ if (input.ToObjectValue() is not PaginateStatement.PaginateValue paginate) return StringValue.Empty;
+
+
+ using (var sb = StringBuilderPool.GetInstance())
+ {
+ var builder = sb.Builder;
+
+ if (paginate.Previous?.IsLink == true)
+ {
+ var title = paginate.Previous.Title;
+
+ if (arguments.HasNamed("previous"))
+ {
+ var str = arguments["previous"].ToStringValue();
+ if (!string.IsNullOrWhiteSpace(str)) title = str;
+ }
+
+ builder.AppendFormat(
+ "{1}",
+ HttpUtility.HtmlAttributeEncode(paginate.Previous.Url),
+ HttpUtility.HtmlEncode(title)
+ );
+ }
+
+ if (paginate.Parts is not null and { Count: > 0 })
+ {
+ foreach (var part in paginate.Parts)
+ {
+ if (part.IsLink)
+ {
+ builder.AppendFormat(
+ "{1}",
+ HttpUtility.HtmlAttributeEncode(part.Url),
+ HttpUtility.HtmlEncode(part.Title)
+ );
+ }
+ else
+ {
+ builder.AppendFormat(
+ "{0}",
+ HttpUtility.HtmlEncode(part.Title)
+ );
+ }
+ }
+ }
+
+ if (paginate.Next?.IsLink == true)
+ {
+ var title = paginate.Next.Title;
+
+ if (arguments.HasNamed("next"))
+ {
+ var str = arguments["next"].ToStringValue();
+ if (!string.IsNullOrWhiteSpace(str)) title = str;
+ }
+
+ builder.AppendFormat(
+ "{1}",
+ HttpUtility.HtmlAttributeEncode(paginate.Previous.Url),
+ HttpUtility.HtmlEncode(title)
+ );
+ }
+
+ return new StringValue(builder.ToString(), false);
+ }
+ }
}
}
diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs
index 1bd1142a..52eb7068 100644
--- a/Fluid/FluidParser.cs
+++ b/Fluid/FluidParser.cs
@@ -340,6 +340,13 @@ public FluidParser()
})
).ElseError("Invalid 'for' tag");
+ var PaginateTag = LogicalExpression.AndSkip(Terms.Text("by")).And(Terms.Integer())
+ .AndSkip(TagEnd)
+ .And(AnyTagsList)
+ .AndSkip(CreateTag("endpaginate"))
+ .ElseError("{{% endpaginate %}} was expected")
+ .Then(x => new PaginateStatement(x.Item1, x.Item2, x.Item3))
+ .ElseError("Invalid paginate tag");
RegisteredTags["break"] = BreakTag;
RegisteredTags["continue"] = ContinueTag;
@@ -355,6 +362,7 @@ public FluidParser()
RegisteredTags["unless"] = UnlessTag;
RegisteredTags["case"] = CaseTag;
RegisteredTags["for"] = ForTag;
+ RegisteredTags["paginate"] = PaginateTag;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static (Expression limitResult, Expression offsetResult, bool reversed) ReadForStatementConfiguration(List modifiers)
diff --git a/Fluid/Values/PaginateableValue.cs b/Fluid/Values/PaginateableValue.cs
new file mode 100644
index 00000000..3578c7ac
--- /dev/null
+++ b/Fluid/Values/PaginateableValue.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Encodings.Web;
+
+namespace Fluid.Values
+{
+ public abstract class PaginateableValue : FluidValue
+ {
+ public override FluidValues Type => FluidValues.Array;
+
+ protected abstract PaginatedData Paginate(Int32 pageSize);
+
+ public abstract string GetUrl(int page);
+
+ public abstract Int32 CurrentPage { get; }
+
+ private PaginatedData _paginatedData;
+
+ private Int32 _pageSize = 10;
+
+ public PaginatedData GetPaginatedData()
+ {
+ if (_paginatedData == null)
+ {
+ _paginatedData = Paginate(PageSize);
+ }
+ return _paginatedData;
+ }
+
+ protected virtual Int32 MaxPageSize => 50;
+
+ protected virtual Int32 MinPageSize => 5;
+
+ protected virtual int DefaultPageSize => 10;
+
+ public Int32 PageSize
+ {
+ get { return _pageSize; }
+ internal set
+ {
+ if (value > MaxPageSize || value < MinPageSize) value = DefaultPageSize;
+ if (_pageSize != value)
+ {
+ _paginatedData = null;
+ _pageSize = value;
+ }
+ }
+ }
+
+ protected override FluidValue GetValue(string name, TemplateContext context)
+ {
+ var data = this.GetPaginatedData();
+ switch (name)
+ {
+ case "total":
+ return NumberValue.Create(data.Total);
+ case "size":
+ return NumberValue.Create(data.Items.Count);
+ case "first":
+ if (data.Items.Count > 0) return data.Items[0];
+ break;
+ case "last":
+ if (data.Items.Count > 0) return data.Items[data.Items.Count - 1];
+ break;
+ }
+ return base.GetValue(name, context);
+ }
+
+ public override bool Equals(FluidValue other)
+ {
+ if (other == null || other.IsNil()) return false;
+ return other is PaginateableValue value && value._paginatedData == _paginatedData;
+ }
+
+ public override bool ToBooleanValue()
+ {
+ return true;
+ }
+
+ public override decimal ToNumberValue()
+ {
+ return GetPaginatedData().Total;
+ }
+
+ public override bool Contains(FluidValue value)
+ {
+ return GetPaginatedData().Items.Contains(value);
+ }
+
+ public override IEnumerable Enumerate()
+ {
+ return GetPaginatedData().Items;
+ }
+
+ public override object ToObjectValue()
+ {
+ return GetPaginatedData().Items;
+ }
+
+ public override string ToStringValue()
+ {
+ return string.Join(string.Empty, GetPaginatedData().Items.Select(x => x.ToStringValue()));
+ }
+
+ public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo)
+ {
+ AssertWriteToParameters(writer, encoder, cultureInfo);
+
+ foreach (var v in GetPaginatedData().Items) writer.Write(v.ToStringValue());
+ }
+ }
+}
diff --git a/Fluid/Values/PaginatedData.cs b/Fluid/Values/PaginatedData.cs
new file mode 100644
index 00000000..00e0cca5
--- /dev/null
+++ b/Fluid/Values/PaginatedData.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Fluid.Values
+{
+ public class PaginatedData
+ {
+ public int Total { get; }
+
+ public IReadOnlyList Items { get; }
+
+ public PaginatedData(IReadOnlyList items, int total)
+ {
+ Total = total;
+ Items = items;
+ }
+ }
+}