From 5c4271a8cbd372453850c88341e404ed475c0006 Mon Sep 17 00:00:00 2001 From: Soar360 Date: Tue, 2 Nov 2021 10:04:32 +0800 Subject: [PATCH 1/3] Implement paginate tag --- Fluid/Ast/PaginateStatement.cs | 247 ++++++++++++++++++++++++++++++ Fluid/Filters/MiscFilters.cs | 89 ++++++++++- Fluid/Values/PaginateableValue.cs | 115 ++++++++++++++ Fluid/Values/PaginatedData.cs | 19 +++ 4 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 Fluid/Ast/PaginateStatement.cs create mode 100644 Fluid/Values/PaginateableValue.cs create mode 100644 Fluid/Values/PaginatedData.cs diff --git a/Fluid/Ast/PaginateStatement.cs b/Fluid/Ast/PaginateStatement.cs new file mode 100644 index 00000000..aa22d5da --- /dev/null +++ b/Fluid/Ast/PaginateStatement.cs @@ -0,0 +1,247 @@ +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 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/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; + } + } +} From 35ad29b3567f3d08d068d8fdced7d86091847221 Mon Sep 17 00:00:00 2001 From: Soar360 Date: Tue, 2 Nov 2021 10:06:52 +0800 Subject: [PATCH 2/3] register paginate tag --- Fluid/FluidParser.cs | 8 ++++++++ 1 file changed, 8 insertions(+) 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) From 98e3b4ee56ce4e52df6219fdd21b229022396b0a Mon Sep 17 00:00:00 2001 From: Soar360 Date: Tue, 2 Nov 2021 10:33:59 +0800 Subject: [PATCH 3/3] add some test about paginate tag --- Fluid.Tests/ParserTests.cs | 27 ++++++++++++++++++++++++--- Fluid/Ast/PaginateStatement.cs | 4 +++- 2 files changed, 27 insertions(+), 4 deletions(-) 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 index aa22d5da..36759f9b 100644 --- a/Fluid/Ast/PaginateStatement.cs +++ b/Fluid/Ast/PaginateStatement.cs @@ -22,6 +22,8 @@ public PaginateStatement(Expression expression, long pageSize, List s _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); @@ -29,7 +31,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text context.EnterChildScope(); try { - paginateableValue.PageSize = (int)_pageSize; + paginateableValue.PageSize = (int)PageSize; var data = paginateableValue.GetPaginatedData(); var paginate = CreatePaginate(paginateableValue, data);