Repository: harttle/liquidjs
Pull request #680 (2024-04-14 11:22:23)

feat: group_by, group_by_exp, find, find_exp
* new filters from Jekyll: group_by, group_by_exp, find, find_exp
* `space` parameter for `json`
* docs for `push`, `pop` filters
* enhanced property expression for `where` filter
* docs for escaping and `outputEscape` option

coveralls: ## Pull Request Test Coverage Report for [Build 8679571704](https://coveralls.io/builds/66899119)


### Details

* **92** of **94**   **(97.87%)**  changed or added relevant lines in **9** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.09%**) to **99.29%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/filters/array.ts](https://coveralls.io/builds/66899119/source?filename=src%2Ffilters%2Farray.ts#L134) | 51 | 53 | 96.23%
<!-- | **Total:** | **92** | **94** | **97.87%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/66899119/badge)](https://coveralls.io/builds/66899119) |
| :-- | --: |
| Change from base [Build 8491095628](https://coveralls.io/builds/66618787): |  -0.09% |
| Covered Lines: | 2306 |
| Relevant Lines: | 2317 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/.gitignore b/.gitignore
index fdc96229e..a47f15151 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ node_modules/
 
 # tmp
 docs/public/js/liquid.browser.min.js
+docs/themes/navy/layout/partial/all-contributors.swig
 dist/
 demo/*/yarn.json
 
diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml
index 46cc138c4..959ea5636 100644
--- a/docs/source/_data/sidebar.yml
+++ b/docs/source/_data/sidebar.yml
@@ -8,6 +8,7 @@ tutorials:
     express: use-in-expressjs.html
   advanced:
     caching: caching.html
+    escaping: escaping.html
     registeration: register-filters-tags.html
     access_scope_in_filters: access-scope-in-filters.html
     parse_parameters: parse-parameters.html
@@ -40,8 +41,12 @@ filters:
   downcase: downcase.html
   escape: escape.html
   escape_once: escape_once.html
+  find: find.html
+  find_exp: find_exp.html
   first: first.html
   floor: floor.html
+  group_by: group_by.html
+  group_by_exp: group_by_exp.html
   join: join.html
   json: json.html
   last: last.html
@@ -51,6 +56,8 @@ filters:
   modulo: modulo.html
   newline_to_br: newline_to_br.html
   plus: plus.html
+  pop: pop.html
+  push: push.html
   prepend: prepend.html
   raw: raw.html
   remove: remove.html
@@ -62,6 +69,7 @@ filters:
   reverse: reverse.html
   round: round.html
   rstrip: rstrip.html
+  shift: shift.html
   size: size.html
   slice: slice.html
   sort: sort.html
@@ -75,6 +83,7 @@ filters:
   truncate: truncate.html
   truncatewords: truncatewords.html
   uniq: uniq.html
+  unshift: unshift.html
   upcase: upcase.html
   url_decode: url_decode.html
   url_encode: url_encode.html
diff --git a/docs/source/filters/find.md b/docs/source/filters/find.md
new file mode 100644
index 000000000..3d2a00c7d
--- /dev/null
+++ b/docs/source/filters/find.md
@@ -0,0 +1,25 @@
+---
+title: find
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Return the first object in an array for which the queried attribute has the given value or return `nil` if no item in the array satisfies the given criteria. For the following `members` array:
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2014, name: 'Jack' }
+]
+```
+
+Input
+```liquid
+{{ members | find: "graduation_year", 2014 | json }}
+```
+
+Output
+```text
+{"graduation_year":2014,"name":"John"}
+```
diff --git a/docs/source/filters/find_exp.md b/docs/source/filters/find_exp.md
new file mode 100644
index 000000000..c56a18bef
--- /dev/null
+++ b/docs/source/filters/find_exp.md
@@ -0,0 +1,25 @@
+---
+title: find_exp
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Return the first object in an array for which the given expression evaluates to true or return `nil` if no item in the array satisfies the evaluated expression.
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2014, name: 'Jack' }
+]
+```
+
+Input
+```liquid
+{{ members | find_exp: "item", "item.graduation_year == 2014" | json }}
+```
+
+Output
+```text
+{"graduation_year":2014,"name":"John"}
+```
diff --git a/docs/source/filters/group_by.md b/docs/source/filters/group_by.md
new file mode 100644
index 000000000..2540fa1b6
--- /dev/null
+++ b/docs/source/filters/group_by.md
@@ -0,0 +1,48 @@
+---
+title: group_by
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Group an array's items by a given property. For `members` array:
+
+```javascript
+const members = [
+  { graduation_year: 2003, name: 'Jay' },
+  { graduation_year: 2003, name: 'John' },
+  { graduation_year: 2004, name: 'Jack' }
+]
+```
+
+Input
+```liquid
+{{ members | group_by: "graduation_year" | json: 2 }}
+```
+
+Output
+```text
+[
+  {
+    "name": 2003,
+    "items": [
+      {
+        "graduation_year": 2003,
+        "name": "Jay"
+      },
+      {
+        "graduation_year": 2003,
+        "name": "John"
+      }
+    ]
+  },
+  {
+    "name": 2004,
+    "items": [
+      {
+        "graduation_year": 2004,
+        "name": "Jack"
+      }
+    ]
+  }
+]
+```
diff --git a/docs/source/filters/group_by_exp.md b/docs/source/filters/group_by_exp.md
new file mode 100644
index 000000000..bd4bb481c
--- /dev/null
+++ b/docs/source/filters/group_by_exp.md
@@ -0,0 +1,48 @@
+---
+title: group_by_exp
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Group an array's items using a Liquid expression. For `members` array below:
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2009, name: 'Jack' }
+]
+```
+
+Input
+```liquid
+{{ members | group_by_exp: "item", "item.graduation_year | truncate: 3, ''" | json: 2 }}
+```
+
+Output
+```text
+[
+  {
+    "name": "201",
+    "items": [
+      {
+        "graduation_year": 2013,
+        "name": "Jay"
+      },
+      {
+        "graduation_year": 2014,
+        "name": "John"
+      }
+    ]
+  },
+  {
+    "name": "200",
+    "items": [
+      {
+        "graduation_year": 2009,
+        "name": "Jack"
+      }
+    ]
+  }
+]
+```
diff --git a/docs/source/filters/json.md b/docs/source/filters/json.md
index f7908298f..196909c7b 100644
--- a/docs/source/filters/json.md
+++ b/docs/source/filters/json.md
@@ -16,3 +16,24 @@ Output
 ```text
 ["foo","bar","coo"]
 ```
+
+## Space
+
+{% since %}v10.11.0{% endsince %}
+
+An additional `space` parameter can be specified to format the JSON.
+
+Input
+```liquid
+{% assign arr = "foo bar coo" | split: " " %}
+{{ arr | json: 4 }}
+```
+
+Output
+```text
+[
+    "foo",
+    "bar",
+    "coo"
+]
+```
diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md
index 51178b399..0ed7480c2 100644
--- a/docs/source/filters/overview.md
+++ b/docs/source/filters/overview.md
@@ -12,7 +12,7 @@ Categories | Filters
 Math | plus, minus, modulo, times, floor, ceil, round, divided_by, abs, at_least, at_most
 String | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last,remove, remove_first, remove_last, truncate, truncatewords
 HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br
-Array | slice, map, sort, sort_natural, uniq, where, first, last, join, reverse, concat, compact, size
+Array | slice, map, sort, sort_natural, uniq, where, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
 Date | date
 Misc | default, json, raw
 
diff --git a/docs/source/filters/pop.md b/docs/source/filters/pop.md
new file mode 100644
index 000000000..b55154f98
--- /dev/null
+++ b/docs/source/filters/pop.md
@@ -0,0 +1,24 @@
+---
+title: pop
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Pop an element from the array. It's NON-DESTRUCTIVE, i.e. it does not mutate the array, but rather make a copy and mutate that.
+
+Input
+```liquid
+{% assign fruits = "apples, oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | pop %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+Output
+```text
+- apples
+- oranges
+```
diff --git a/docs/source/filters/push.md b/docs/source/filters/push.md
new file mode 100644
index 000000000..1844708b8
--- /dev/null
+++ b/docs/source/filters/push.md
@@ -0,0 +1,25 @@
+---
+title: push
+---
+
+{% since %}v10.8.0{% endsince %}
+
+Push an element into array. It's NON-DESTRUCTIVE, i.e. it does not mutate the array, but rather make a copy and mutate that.
+
+Input
+```liquid
+{% assign fruits = "apples, oranges" | split: ", " %}
+
+{% assign everything = fruits | push: "peaches" %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+Output
+```text
+- apples
+- oranges
+- peaches
+```
diff --git a/docs/source/filters/shift.md b/docs/source/filters/shift.md
new file mode 100644
index 000000000..63e547562
--- /dev/null
+++ b/docs/source/filters/shift.md
@@ -0,0 +1,24 @@
+---
+title: shift
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Shift an element from the array. It's NON-DESTRUCTIVE, i.e. it does not mutate the array, but rather make a copy and mutate that.
+
+Input
+```liquid
+{% assign fruits = "apples, oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | shift %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+Output
+```text
+- oranges
+- peaches
+```
diff --git a/docs/source/filters/unshift.md b/docs/source/filters/unshift.md
new file mode 100644
index 000000000..77b568c1b
--- /dev/null
+++ b/docs/source/filters/unshift.md
@@ -0,0 +1,25 @@
+---
+title: unshift
+---
+
+{% since %}v10.11.0{% endsince %}
+
+Unshift an element to the front of the array. It's NON-DESTRUCTIVE, i.e. it does not mutate the array, but rather make a copy and mutate that.
+
+Input
+```liquid
+{% assign fruits = "oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | unshift: "apples" %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+Output
+```text
+- apples
+- oranges
+- peaches
+```
diff --git a/docs/source/filters/where.md b/docs/source/filters/where.md
index b2bc16495..2054fac19 100644
--- a/docs/source/filters/where.md
+++ b/docs/source/filters/where.md
@@ -79,4 +79,28 @@ Output
 Featured product: Hawaiian print sweater vest
 ```
 
+Additionally, `property` can be any valid Liquid variable expression as used in output syntax, except that the scope of this expression is within each item. For the following `products` array:
+
+```javascript
+const products = [
+    { meta: { details: { class: 'A' } }, order: 1 },
+    { meta: { details: { class: 'B' } }, order: 2 },
+    { meta: { details: { class: 'B' } }, order: 3 }
+]
+```
+
+Input
+```liquid
+{% assign selected = products | where: 'meta.details["class"]', "B" %}
+{% for item in selected -%}
+- {{ item.order }}
+{% endfor %}
+```
+
+Ouput
+```text
+- 2
+- 3
+```
+
 [truthy]: ../tutorials/truthy-and-falsy.html
diff --git a/docs/source/tutorials/differences.md b/docs/source/tutorials/differences.md
index 4e372e01a..036ea5416 100644
--- a/docs/source/tutorials/differences.md
+++ b/docs/source/tutorials/differences.md
@@ -16,7 +16,7 @@ In the meantime, it's now implemented in JavaScript, that means it has to be mor
 * **Async as first-class citizen**. Filters and tags can be implemented asynchronously by return a `Promise`.
 * **Also can be sync**. For scenarios that are not I/O intensive, render synchronously can be much faster. You can call synchronous APIs like `.renderSync()` as long as all the filters and tags in template support to be rendered synchronously. All builtin filters/tags support both sync and async render.
 * **[Abstract file system][afs]**. Along with async feature, LiquidJS can be used to serve templates stored in Databases [#414][#414], on remote HTTP server [#485][#485], and so on.
-* **Additional tags and filters** like `layout` and `json`.
+* **Additional tags and filters** like `layout` and `json`, see below for details.
 
 ## Differences
 
@@ -29,11 +29,16 @@ Though we're trying to be compatible with the Ruby version, there are still some
 * Iteration order for objects. The iteration order of JavaScript objects, and thus LiquidJS objects, is a combination of the insertion order for string keys, and ascending order for number-like keys, while the iteration order of Ruby Hash is simply the insertion order.
 * Sort stability. The [sort][sort] stability is also not defined in both shopify/liquid and LiquidJS, but it's [considered stable][stable-sort] for LiquidJS in Node.js 12+ and Google Chrome 70+.
 * Trailing unmatched characters inside filters are allowed in shopify/liquid but not in LiquidJS. It means filter arguments without a colon like `{%raw%}{{ "a b" | split " "}}{%endraw%}` will throw an error in LiquidJS. This is intended to improve Liquid usability, see [#208][#208] and [#212][#212].
-* LiquidJS has additional tags: [layout][layout] and corresponding `block` tag.
+* LiquidJS has more tags/filters than [the Liquid language][liquid]:
+    * LiquidJS-defined tags: [layout][layout], [render][render] and corresponding `block` tag.
+    * LiquidJS-defined filters: [json][json].
+    * Tags/filters that don't depend on Shopify platform are borrowed from [Shopify][shopify-tags].
+    * Tags/filters that don't depend on Jekyll framework are borrowed from [Jekyll][jekyll-filters]
 * LiquidJS [date][date] filter supports `%q` for date ordinals like `{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb`
 
 [date]: https://liquidjs.com/filters/date.html
-[layout]: https://liquidjs.com/tags/layout.html
+[layout]: ../tags/layout.html
+[render]: ../tags/render.html
 [json]: https://liquidjs.com/filters/json.html
 [#26]: https://github.com/harttle/liquidjs/pull/26
 [#59]: https://github.com/harttle/liquidjs/issues/59
@@ -47,3 +52,6 @@ Though we're trying to be compatible with the Ruby version, there are still some
 [plugins]: ./plugins.html#Plugin-List
 [ruby-liquid]: https://github.com/Shopify/liquid
 [afs]: https://liquidjs.com/tutorials/render-file.html#Abstract-File-System
+[liquid]: https://shopify.github.io/liquid/basics/introduction/
+[shopify-tags]: https://shopify.dev/docs/api/liquid/tags
+[jekyll-filters]: https://jekyllrb.com/docs/liquid/filters/
diff --git a/docs/source/tutorials/escaping.md b/docs/source/tutorials/escaping.md
new file mode 100644
index 000000000..7bcbd03ee
--- /dev/null
+++ b/docs/source/tutorials/escaping.md
@@ -0,0 +1,76 @@
+---
+title: Escaping
+---
+
+Escaping is important in all languages, including LiquidJS. While escaping has 2 different meanings for a template engine:
+
+1. Escaping for the output, i.e. HTML escape. Used to escape HTML special characters so the output will not break HTML structures, aka HTML safe.
+2. Escaping for the language itself, i.e. Liquid escape. Used to output strings that's considered special in Liquid language. This will be useful when you're writing an article in Liquid template to introduce Liquid language.
+
+## HTML Escape
+
+By default output is not escaped. While you can use [escape][escape] filter for this:
+
+Input
+```liquid
+{{ "1 < 2" | escape }}
+```
+
+Output
+```text
+1 &lt; 2
+```
+
+There's also [escape_once][escape_once], [newline_to_br][newline_to_br], [strip_html][strip_html] filters for you to fine tune your output.
+
+In cases where variables are mostly not trusted, [outputEscape][outputEscape] can be set to `"escape"` to apply escape by default. In this case, when you need some output not to be escaped, [raw][raw] filter can be used:
+
+Input
+```liquid
+{{ "1 < 2" }}
+{{ "<button>OK</button>" | raw }}
+```
+
+Output
+```text
+1 &lt; 2
+<button>OK</button>
+```
+
+## Liquid Escape
+
+To disable Liquid language and output strings like `{{` and `{%`, the [raw][raw] tag can be used.
+
+Input
+```liquid
+{% raw %}
+  In LiquidJS, {{ this | escape }} will be HTML-escaped, but
+  {{{ that }}} will not.
+{% endraw %}
+```
+
+Output
+```text
+In LiquidJS, {{ this | escape }} will be HTML-escaped, but
+{{{ that }}} will not.
+```
+
+Within strings literals in LiquidJS template, `\` can be used to escape special characters in string syntax. For example:
+
+Input
+```liquid
+{{ "\"" }}
+```
+
+Output
+```liquid
+"
+```
+
+[outputEscape]: ./options.html#outputEscape
+[escape]: ../filters/escape.html
+[raw]: ../filters/raw.html
+[escape_once]: ../filters/escape.html
+[strip_html]: ../filters/strip_html.html
+[newline_to_br]: ../filters/newline_to_br.html
+[raw]: ../tags/raw.html
diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md
index 4c91a4a0a..db4124c93 100644
--- a/docs/source/tutorials/options.md
+++ b/docs/source/tutorials/options.md
@@ -102,6 +102,14 @@ Before 2.0.1, <code>extname</code> is set to `.liquid` by default. To change tha
 
 it defaults to false.  For example, when set to true, a blank string would evaluate to false with jsTruthy. With Shopify's truthiness, a blank string is true.
 
+## outputEscape
+
+[outputEscape][outputEscape] can be used to automatically escape output strings. It can be one of `"escape"`, `"json"`, or `(val: unknown) => string`, defaults to `undefined`.
+
+- For untrusted output variables, set `outputEscape: "escape"` makes them be HTML escaped by default. You'll need [raw][raw] filter for direct output.
+- `"json"` is useful when you're using LiquidJS to create valid JSON files.
+- It can even be a function which allows you to control what variables are output throughout LiquidJS. Please note the input can be any type other than string, e.g. an filter returned an non-string value.
+
 ## Date
 
 **timezoneOffset** is used to specify a different timezone to output dates, your local timezone will be used if not specified. For example, set `timezoneOffset: 0` to output all dates in UTC/GMT 00:00.
@@ -151,3 +159,5 @@ Parameter orders are ignored by default, for ea `{% for i in (1..8) reversed lim
 [wc]: ./whitespace-control.html
 [intro]: ./intro-to-liquid.html
 [jekyllInclude]: /api/interfaces/LiquidOptions.html#jekyllInclude
+[raw]: ../filters/raw.html
+[outputEscape]: /api/interfaces/LiquidOptions.html#outputEscape
diff --git a/docs/source/zh-cn/filters/find.md b/docs/source/zh-cn/filters/find.md
new file mode 100644
index 000000000..baebe69b8
--- /dev/null
+++ b/docs/source/zh-cn/filters/find.md
@@ -0,0 +1,25 @@
+---
+title: find
+---
+
+{% since %}v10.11.0{% endsince %}
+
+在数组中找到给定的属性为给定的值的第一个元素并返回；如果没有这样的元素则返回 `nil`。对于 `members` 数组：
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2014, name: 'Jack' }
+]
+```
+
+输入
+```liquid
+{{ members | find: "graduation_year", 2014 | json }}
+```
+
+输出
+```text
+{"graduation_year":2014,"name":"John"}
+```
diff --git a/docs/source/zh-cn/filters/find_exp.md b/docs/source/zh-cn/filters/find_exp.md
new file mode 100644
index 000000000..60a55c70e
--- /dev/null
+++ b/docs/source/zh-cn/filters/find_exp.md
@@ -0,0 +1,25 @@
+---
+title: find_exp
+---
+
+{% since %}v10.11.0{% endsince %}
+
+找到数组中给定的表达式值为 `true` 的第一个元素，如果没有这样的元素则返回 `nil`。对于下面的 `members` 数组：
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2014, name: 'Jack' }
+]
+```
+
+输入
+```liquid
+{{ members | find_exp: "item", "item.graduation_year == 2014" | json }}
+```
+
+输出
+```text
+{"graduation_year":2014,"name":"John"}
+```
diff --git a/docs/source/zh-cn/filters/group_by.md b/docs/source/zh-cn/filters/group_by.md
new file mode 100644
index 000000000..2ce1b08ac
--- /dev/null
+++ b/docs/source/zh-cn/filters/group_by.md
@@ -0,0 +1,48 @@
+---
+title: group_by
+---
+
+{% since %}v10.11.0{% endsince %}
+
+把数组元素按照给定的属性的值分组。对于 `members` 数组：
+
+```javascript
+const members = [
+  { graduation_year: 2003, name: 'Jay' },
+  { graduation_year: 2003, name: 'John' },
+  { graduation_year: 2004, name: 'Jack' }
+]
+```
+
+输入
+```liquid
+{{ members | group_by: "graduation_year" | json: 2 }}
+```
+
+输出
+```text
+[
+  {
+    "name": 2003,
+    "items": [
+      {
+        "graduation_year": 2003,
+        "name": "Jay"
+      },
+      {
+        "graduation_year": 2003,
+        "name": "John"
+      }
+    ]
+  },
+  {
+    "name": 2004,
+    "items": [
+      {
+        "graduation_year": 2004,
+        "name": "Jack"
+      }
+    ]
+  }
+]
+```
diff --git a/docs/source/zh-cn/filters/group_by_exp.md b/docs/source/zh-cn/filters/group_by_exp.md
new file mode 100644
index 000000000..a68aaaee6
--- /dev/null
+++ b/docs/source/zh-cn/filters/group_by_exp.md
@@ -0,0 +1,48 @@
+---
+title: group_by_exp
+---
+
+{% since %}v10.11.0{% endsince %}
+
+把数组元素按照给定的 Liquid 表达式的值分组。对于 `members` 数组：
+
+```javascript
+const members = [
+  { graduation_year: 2013, name: 'Jay' },
+  { graduation_year: 2014, name: 'John' },
+  { graduation_year: 2009, name: 'Jack' }
+]
+```
+
+输入
+```liquid
+{{ members | group_by_exp: "item", "item.graduation_year | truncate: 3, ''" | json: 2 }}
+```
+
+输出
+```text
+[
+  {
+    "name": "201",
+    "items": [
+      {
+        "graduation_year": 2013,
+        "name": "Jay"
+      },
+      {
+        "graduation_year": 2014,
+        "name": "John"
+      }
+    ]
+  },
+  {
+    "name": "200",
+    "items": [
+      {
+        "graduation_year": 2009,
+        "name": "Jack"
+      }
+    ]
+  }
+]
+```
diff --git a/docs/source/zh-cn/filters/json.md b/docs/source/zh-cn/filters/json.md
index 7089b46f2..14f164733 100644
--- a/docs/source/zh-cn/filters/json.md
+++ b/docs/source/zh-cn/filters/json.md
@@ -16,3 +16,24 @@ title: json
 ```text
 ["foo","bar","coo"]
 ```
+
+## 格式化
+
+{% since %}v10.11.0{% endsince %}
+
+可以指定一个 `space` 参数来格式化 JSON。
+
+Input
+```liquid
+{% assign arr = "foo bar coo" | split: " " %}
+{{ arr | json: 4 }}
+```
+
+Output
+```text
+[
+    "foo",
+    "bar",
+    "coo"
+]
+```
diff --git a/docs/source/zh-cn/filters/overview.md b/docs/source/zh-cn/filters/overview.md
index 0be713afd..5938d4015 100644
--- a/docs/source/zh-cn/filters/overview.md
+++ b/docs/source/zh-cn/filters/overview.md
@@ -12,7 +12,7 @@ LiquidJS 共支持 40+ 个过滤器，可以分为如下几类：
 数学 | plus, minus, modulo, times, floor, ceil, round, divided_by, abs, at_least, at_most
 字符串 | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last, remove, remove_first, remove_last, truncate, truncatewords
 HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br
-数组 | slice, map, sort, sort_natural, uniq, wheres, first, last, join, reverse, concat, compact, size
+数组 | slice, map, sort, sort_natural, uniq, where, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
 日期 | date
 其他 | default, json
 
diff --git a/docs/source/zh-cn/filters/pop.md b/docs/source/zh-cn/filters/pop.md
new file mode 100644
index 000000000..fb72a9788
--- /dev/null
+++ b/docs/source/zh-cn/filters/pop.md
@@ -0,0 +1,24 @@
+---
+title: pop
+---
+
+{% since %}v10.11.0{% endsince %}
+
+从数组末尾弹出一个元素。注意该操作不会改变原数组，而是在一份拷贝上操作。
+
+输入
+```liquid
+{% assign fruits = "apples, oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | pop %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+输出
+```text
+- apples
+- oranges
+```
diff --git a/docs/source/zh-cn/filters/push.md b/docs/source/zh-cn/filters/push.md
new file mode 100644
index 000000000..ea24324d9
--- /dev/null
+++ b/docs/source/zh-cn/filters/push.md
@@ -0,0 +1,25 @@
+---
+title: push
+---
+
+{% since %}v10.8.0{% endsince %}
+
+在数组中添加一个元素。注意该操作不会改变原数组，而是在一份拷贝上操作。
+
+输入
+```liquid
+{% assign fruits = "apples, oranges" | split: ", " %}
+
+{% assign everything = fruits | push: "peaches" %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+输出
+```text
+- apples
+- oranges
+- peaches
+```
diff --git a/docs/source/zh-cn/filters/shift.md b/docs/source/zh-cn/filters/shift.md
new file mode 100644
index 000000000..e2bb70481
--- /dev/null
+++ b/docs/source/zh-cn/filters/shift.md
@@ -0,0 +1,24 @@
+---
+title: shift
+---
+
+{% since %}v10.11.0{% endsince %}
+
+从数组头部弹出一个元素。注意该操作不会改变原数组，而是在一份拷贝上操作。
+
+输入
+```liquid
+{% assign fruits = "apples, oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | shift %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+输出
+```text
+- oranges
+- peaches
+```
diff --git a/docs/source/zh-cn/filters/unshift.md b/docs/source/zh-cn/filters/unshift.md
new file mode 100644
index 000000000..e29ebd2f7
--- /dev/null
+++ b/docs/source/zh-cn/filters/unshift.md
@@ -0,0 +1,25 @@
+---
+title: unshift
+---
+
+{% since %}v10.11.0{% endsince %}
+
+往数组头部添加一个元素。注意该操作不会改变原数组，而是在一份拷贝上操作。
+
+输入
+```liquid
+{% assign fruits = "oranges, peaches" | split: ", " %}
+
+{% assign everything = fruits | unshift: "apples" %}
+
+{% for item in everything %}
+- {{ item }}
+{% endfor %}
+```
+
+输出
+```text
+- apples
+- oranges
+- peaches
+```
diff --git a/docs/source/zh-cn/filters/where.md b/docs/source/zh-cn/filters/where.md
index 592ff5822..07b858317 100644
--- a/docs/source/zh-cn/filters/where.md
+++ b/docs/source/zh-cn/filters/where.md
@@ -79,4 +79,29 @@ Featured product: {{ new_shirt.title }}
 Featured product: Hawaiian print sweater vest
 ```
 
+此外 `property` 可以是任意合法的变量表达式，就像在**输出**结构中一样，只是它的上下文是数组的每一个元素。对于下面的 `products` 数组：
+
+```javascript
+const products = [
+    { meta: { details: { class: 'A' } }, order: 1 },
+    { meta: { details: { class: 'B' } }, order: 2 },
+    { meta: { details: { class: 'B' } }, order: 3 }
+]
+```
+
+输入
+```liquid
+{% assign selected = products | where: 'meta.details["class"]', "B" %}
+{% for item in selected -%}
+- {{ item.order }}
+{% endfor %}
+```
+
+输出
+```text
+- 2
+- 3
+```
+
+
 [truthy]: ../tutorials/truthy-and-falsy.html
diff --git a/docs/source/zh-cn/tutorials/differences.md b/docs/source/zh-cn/tutorials/differences.md
index d86356c64..3dff7367a 100644
--- a/docs/source/zh-cn/tutorials/differences.md
+++ b/docs/source/zh-cn/tutorials/differences.md
@@ -29,10 +29,14 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
 * 对象的迭代顺序。JavaScript 对象的迭代顺序是插入顺序和数字键递增顺序的组合，但 Ruby Hash 中只是插入顺序（JavaScript 字面量 Object 和 Ruby 字面量 Hash 的插入顺序解释也不同）。
 * 排序稳定性。shopify/liquid 和 LiquidJS 都没有定义 [sort][sort] 过滤器的稳定性在，它取决于 Ruby/JavaScript 内置的排序算法，在 Node.js 12+ 和 Google Chrome 70+ LiquidJS 的排序是 [稳定的][stable-sort]。
 * shopify/liquid 允许过滤器尾部的未匹配字符，但 LiquidJS 不允许。这就是说如果过滤器参数前忘记写冒号比如 `{%raw%}{{ "a b" | split " "}}{%endraw%}` LiquidJS 会抛出异常。这是为了提升 Liquid 模板的易用性，参考 [#208][#208] 和 [#212][#212]。
-* LiquidJS 有额外的标签：[layout][layout] 和相应的 `block`。
-* LiquidJS 有额外的过滤器：[json][json]。
-
-[layout]: https://liquidjs.com/tags/layout.html
+* LiquidJS 比 [Liquid 语言][liquid] 有更多的标签和过滤器：
+    * LiquidJS 自己定义的标签：[layout][layout]、[render][render] 和相应的 `block`。
+    * LiquidJS 自己定义的过滤器：[json][json]。
+    * 从 [Shopify][shopify-tags] 借来的不依赖 Shopify 平台的标签/过滤器。
+    * 从 [Jekyll][jekyll-filters] 借来的不依赖 Jekyll 框架的标签/过滤器。
+
+[layout]: ../tags/layout.html
+[render]: ../tags/render.html
 [json]: https://liquidjs.com/filters/json.html
 [#26]: https://github.com/harttle/liquidjs/pull/26
 [#59]: https://github.com/harttle/liquidjs/issues/59
@@ -46,3 +50,6 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
 [plugins]: ./plugins.html#插件列表
 [ruby-liquid]: https://github.com/Shopify/liquid
 [afs]: https://liquidjs.com/tutorials/render-file.html#Abstract-File-System
+[liquid]: https://shopify.github.io/liquid/basics/introduction/
+[shopify-tags]: https://shopify.dev/docs/api/liquid/tags
+[jekyll-filters]: https://jekyllrb.com/docs/liquid/filters/
diff --git a/docs/source/zh-cn/tutorials/escaping.md b/docs/source/zh-cn/tutorials/escaping.md
new file mode 100644
index 000000000..042bcc34e
--- /dev/null
+++ b/docs/source/zh-cn/tutorials/escaping.md
@@ -0,0 +1,76 @@
+---
+title: 转义
+---
+
+LiquidJS 种转义有两种含义：
+
+1. 输出语言的转义，即 HTML 转义。用来让输出的变量不包含 HTML 特殊字符，不影响 HTML 的结构，也就是输出 HTML 安全的字符串。
+2. 语言自己的转义，即 Liquid 转义。用来输出包含对于 Liquid 语言来说是特殊字符的字符串，比如你在使用 Liquid 模板语言来编写一篇介绍 Liquid 语法的文章时就会需要 Liquid 转义。
+
+## HTML 转义
+
+默认情况下输出是不转义的，但你可以用 [escape][escape] 过滤器来做 HTML 转义：
+
+输入
+```liquid
+{{ "1 < 2" | escape }}
+```
+
+输出
+```text
+1 &lt; 2
+```
+
+LiquidJS 也提供了其他过滤器来支持不同的转义需求：[escape_once][escape_once], [newline_to_br][newline_to_br], [strip_html][strip_html]。
+
+当输出的变量不被信任时，可以把 [outputEscape][outputEscape] 参数设置为 `"escape"` 来启用默认 HTML 转义。这种情况下，如果你需要某个输出不被转义，则需要使用 [raw][raw] 过滤器：
+
+输入
+```liquid
+{{ "1 < 2" }}
+{{ "<button>OK</button>" | raw }}
+```
+
+输出
+```text
+1 &lt; 2
+<button>OK</button>
+```
+
+## Liquid 转义
+
+为了输出 Liquid 的特殊字符比如 `{{` 和 `{%`，你需要 [raw][raw] 标签。
+
+输入
+```liquid
+{% raw %}
+  In LiquidJS, {{ this | escape }} will be HTML-escaped, but
+  {{{ that }}} will not.
+{% endraw %}
+```
+
+输出
+```text
+In LiquidJS, {{ this | escape }} will be HTML-escaped, but
+{{{ that }}} will not.
+```
+
+Within strings literals in LiquidJS template, `\` can be used to escape special characters in string syntax. For example:
+
+输入
+```liquid
+{{ "\"" }}
+```
+
+输出
+```liquid
+"
+```
+
+[outputEscape]: ./options.html#outputEscape
+[escape]: ../filters/escape.html
+[raw]: ../filters/raw.html
+[escape_once]: ../filters/escape.html
+[strip_html]: ../filters/strip_html.html
+[newline_to_br]: ../filters/newline_to_br.html
+[raw]: ../tags/raw.html
diff --git a/docs/source/zh-cn/tutorials/options.md b/docs/source/zh-cn/tutorials/options.md
index c73ed7456..a69442039 100644
--- a/docs/source/zh-cn/tutorials/options.md
+++ b/docs/source/zh-cn/tutorials/options.md
@@ -100,6 +100,14 @@ LiquidJS 把这个选项默认值设为 <code>true</code> 以兼容于 shopify/l
 
 例如，空字符串在 JavaScript 中为假（`jsTruthy` 为 `true` 时），在 Shopify 真值表中为真。
 
+## outputEscape
+
+[outputEscape][outputEscape] 用来自动转义输出。它的值可以是 `"escape"`、`"json"` 或 `(val: unknown) => string`，默认为 `undefined`。
+
+- 如果被输出的变量不被信任，可以设置 `outputEscape: "escape"` 来自动把它们 HTML 转义。如果要直接输出则需要使用 [raw][raw] 过滤器。
+- 如果你在用 LiquidJS 来生产 JSON 文件，可以设置为 `"json"`。
+- `outputEscape` 甚至可以是函数，你可以借此控制整个 LiquidJS 的变量输出。注意函数的输入不一定是字符串，因为过滤器的返回值可以不是字符串，你的函数将会接到这个值。
+
 ## 时间日期和时区
 
 **timezoneOffset** 用来指定一个和你当地时区不同的时区，所有日期和时间输出时都转换到这个指定的时区。例如设置 `timezoneOffset: 0` 将会把所有日期按照 UTC/GMT 00:00 来输出。
@@ -147,3 +155,5 @@ LiquidJS 把这个选项默认值设为 <code>true</code> 以兼容于 shopify/l
 [wc]: ./whitespace-control.html
 [intro]: ./intro-to-liquid.html
 [jekyllInclude]: /api/interfaces/LiquidOptions.html#jekyllInclude
+[raw]: ../filters/raw.html
+[outputEscape]: /api/interfaces/LiquidOptions.html#outputEscape
diff --git a/docs/themes/navy/languages/en.yml b/docs/themes/navy/languages/en.yml
index ba5b764bd..1718428eb 100644
--- a/docs/themes/navy/languages/en.yml
+++ b/docs/themes/navy/languages/en.yml
@@ -40,6 +40,7 @@ sidebar:
 
     advanced: Advanced
     caching: Caching
+    escaping: Escaping
     registeration: Register Filters/Tags
     access_scope_in_filters: Access Scope in Filters
     parse_parameters: Parse Parameters
diff --git a/docs/themes/navy/languages/zh-cn.yml b/docs/themes/navy/languages/zh-cn.yml
index 9333eec19..82dfc0a59 100644
--- a/docs/themes/navy/languages/zh-cn.yml
+++ b/docs/themes/navy/languages/zh-cn.yml
@@ -40,6 +40,7 @@ sidebar:
 
     advanced: 高级主题
     caching: 缓存
+    escaping: 转义
     registeration: 注册标签/过滤器
     access_scope_in_filters: 过滤器里访问上下文
     parse_parameters: 参数解析
diff --git a/docs/themes/navy/layout/partial/all-contributors.swig b/docs/themes/navy/layout/partial/all-contributors.swig
deleted file mode 100644
index 1e183f1d0..000000000
--- a/docs/themes/navy/layout/partial/all-contributors.swig
+++ /dev/null
@@ -1,85 +0,0 @@
-
-<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
-<!-- prettier-ignore-start -->
-<!-- markdownlint-disable -->
-<table>
-  <tbody>
-    <tr>
-      <td align="center" valign="top" width="0%"><a href="https://harttle.land"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/chenos"><img src="https://avatars0.githubusercontent.com/u/2993310?v=4?s=100" width="100px;" alt="chenos"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://zachleat.com/"><img src="https://avatars2.githubusercontent.com/u/39355?v=4?s=100" width="100px;" alt="Zach Leatherman"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/thardy"><img src="https://avatars3.githubusercontent.com/u/120636?v=4?s=100" width="100px;" alt="Tim Hardy"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://paulrobertlloyd.com/"><img src="https://avatars3.githubusercontent.com/u/813383?v=4?s=100" width="100px;" alt="Paul Robert Lloyd"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://twitter.com/alecdotbiz"><img src="https://avatars2.githubusercontent.com/u/1925840?v=4?s=100" width="100px;" alt="Alec Larson"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/pmalouin"><img src="https://avatars1.githubusercontent.com/u/1411117?v=4?s=100" width="100px;" alt="Patrick Malouin"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://jaswrks.com"><img src="https://avatars3.githubusercontent.com/u/1563559?v=4?s=100" width="100px;" alt="jaswrks"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://oott123.com"><img src="https://avatars2.githubusercontent.com/u/905663?v=4?s=100" width="100px;" alt="三三"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/ssendev"><img src="https://avatars0.githubusercontent.com/u/450793?v=4?s=100" width="100px;" alt="ssendev"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/wojtask9"><img src="https://avatars3.githubusercontent.com/u/6099236?v=4?s=100" width="100px;" alt="wojtask9"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/thelornenelson"><img src="https://avatars3.githubusercontent.com/u/24596583?v=4?s=100" width="100px;" alt="Andrew Barclay"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://www.stam.pr/"><img src="https://avatars2.githubusercontent.com/u/142338?v=4?s=100" width="100px;" alt="Cory Mawhorter"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/thehappybug"><img src="https://avatars0.githubusercontent.com/u/3393530?v=4?s=100" width="100px;" alt="Mehdi Jaffery"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/robinbijlani"><img src="https://avatars0.githubusercontent.com/u/2503108?v=4?s=100" width="100px;" alt="Robin Bijlani"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://www.rmkennedy.com"><img src="https://avatars3.githubusercontent.com/u/8356669?v=4?s=100" width="100px;" alt="Ryan Kennedy"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/strax"><img src="https://avatars2.githubusercontent.com/u/587213?v=4?s=100" width="100px;" alt="Sami Kukkonen"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://ScottFreeCode.github.io/"><img src="https://avatars3.githubusercontent.com/u/16506071?v=4?s=100" width="100px;" alt="Scott Santucci"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://stevenrescigno.com"><img src="https://avatars3.githubusercontent.com/u/8505293?v=4?s=100" width="100px;" alt="Steven "/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://efcl.info/"><img src="https://avatars1.githubusercontent.com/u/19714?v=4?s=100" width="100px;" alt="azu"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/wyozi"><img src="https://avatars3.githubusercontent.com/u/4894573?v=4?s=100" width="100px;" alt="Joonas"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/jamelait"><img src="https://avatars1.githubusercontent.com/u/14369255?v=4?s=100" width="100px;" alt="Jamel A."/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://brandonpittman.net"><img src="https://avatars0.githubusercontent.com/u/967145?v=4?s=100" width="100px;" alt="Brandon Pittman"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/tgrandgent"><img src="https://avatars3.githubusercontent.com/u/17069042?v=4?s=100" width="100px;" alt="tgrandgent"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/mastodon0"><img src="https://avatars1.githubusercontent.com/u/7924332?v=4?s=100" width="100px;" alt="Martin Schuster"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://js.chenlei.me"><img src="https://avatars0.githubusercontent.com/u/6339390?v=4?s=100" width="100px;" alt="Ray"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/CriGoT"><img src="https://avatars0.githubusercontent.com/u/1936786?v=4?s=100" width="100px;" alt="Cristofer Gonzales"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://www.raymondcamden.com"><img src="https://avatars3.githubusercontent.com/u/393660?v=4?s=100" width="100px;" alt="Raymond Camden"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://stedman.dev"><img src="https://avatars1.githubusercontent.com/u/183122?v=4?s=100" width="100px;" alt="Steve Stedman"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://ciccarello.me"><img src="https://avatars0.githubusercontent.com/u/11273838?v=4?s=100" width="100px;" alt="Anthony Ciccarello"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://twitter.com/IAmTrySound"><img src="https://avatars0.githubusercontent.com/u/5635476?v=4?s=100" width="100px;" alt="Bogdan Chadkin"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://hightouch.io"><img src="https://avatars0.githubusercontent.com/u/5959235?v=4?s=100" width="100px;" alt="Tejas Manohar"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://about.me/peterdehaan"><img src="https://avatars2.githubusercontent.com/u/557895?v=4?s=100" width="100px;" alt="Peter deHaan"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/amit777"><img src="https://avatars0.githubusercontent.com/u/2703309?v=4?s=100" width="100px;" alt="amit777"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://www.ifi.uzh.ch/en/ce/people/schuldenzucker.html"><img src="https://avatars3.githubusercontent.com/u/1100776?v=4?s=100" width="100px;" alt="Steffen Schuldenzucker"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/Pixcell"><img src="https://avatars0.githubusercontent.com/u/4005291?v=4?s=100" width="100px;" alt="Pixcell"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://jasonet.co"><img src="https://avatars.githubusercontent.com/u/10660468?v=4?s=100" width="100px;" alt="Jason Etcovitch"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/kayuapi"><img src="https://avatars.githubusercontent.com/u/10304328?v=4?s=100" width="100px;" alt="ZC"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://memmie.lenglet.name"><img src="https://avatars.githubusercontent.com/u/729275?v=4?s=100" width="100px;" alt="Memmie Lenglet"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/ilhamdev0"><img src="https://avatars.githubusercontent.com/u/57636145?v=4?s=100" width="100px;" alt="ilhamdev0"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/c412216887"><img src="https://avatars.githubusercontent.com/u/29691650?v=4?s=100" width="100px;" alt="一饮一啄皆是人生"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://digitalinspiration.com/"><img src="https://avatars.githubusercontent.com/u/1344071?v=4?s=100" width="100px;" alt="Amit Agarwal"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://n1ru4l.cloud/"><img src="https://avatars.githubusercontent.com/u/14338007?v=4?s=100" width="100px;" alt="Laurin Quast"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/mattvague"><img src="https://avatars.githubusercontent.com/u/64985?v=4?s=100" width="100px;" alt="Matt Vague"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/bglw"><img src="https://avatars.githubusercontent.com/u/40188355?v=4?s=100" width="100px;" alt="Liam Bigelow"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://about.me/jasonkurian"><img src="https://avatars.githubusercontent.com/u/2642545?v=4?s=100" width="100px;" alt="Jason Kurian"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/dphm"><img src="https://avatars.githubusercontent.com/u/1707217?v=4?s=100" width="100px;" alt="d pham (they/them)"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://www.aleksandrhovhannisyan.com/"><img src="https://avatars.githubusercontent.com/u/19352442?v=4?s=100" width="100px;" alt="Aleksandr Hovhannisyan"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/jg-rp"><img src="https://avatars.githubusercontent.com/u/72664870?v=4?s=100" width="100px;" alt="jg-rp"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/ameyaapte1"><img src="https://avatars.githubusercontent.com/u/16054747?v=4?s=100" width="100px;" alt="Ameya Apte"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/tbdrz"><img src="https://avatars.githubusercontent.com/u/50599116?v=4?s=100" width="100px;" alt="tbdrz"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://santialbo.com"><img src="https://avatars.githubusercontent.com/u/1557563?v=4?s=100" width="100px;" alt="Santi Albo"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/YahangWu"><img src="https://avatars.githubusercontent.com/u/12295975?v=4?s=100" width="100px;" alt="Yahang Wu"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/hongl-1"><img src="https://avatars.githubusercontent.com/u/101576612?v=4?s=100" width="100px;" alt="hongl"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/zxx-457"><img src="https://avatars.githubusercontent.com/u/114141362?v=4?s=100" width="100px;" alt="zxx-457"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/prassie"><img src="https://avatars.githubusercontent.com/u/1357831?v=4?s=100" width="100px;" alt="prassie"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://slavv.com/"><img src="https://avatars.githubusercontent.com/u/713329?v=4?s=100" width="100px;" alt="Slav Ivanov"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://www.orgflow.io/"><img src="https://avatars.githubusercontent.com/u/3889090?v=4?s=100" width="100px;" alt="Daniel Rosenberg"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/bobgubko"><img src="https://avatars.githubusercontent.com/u/733312?v=4?s=100" width="100px;" alt="bobgubko"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/bangank36"><img src="https://avatars.githubusercontent.com/u/10071857?v=4?s=100" width="100px;" alt="BaNgan"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/mahyar-pasarzangene"><img src="https://avatars.githubusercontent.com/u/16485039?v=4?s=100" width="100px;" alt="Mahyar Pasarzangene"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://hubelbauer.net/"><img src="https://avatars.githubusercontent.com/u/6831144?v=4?s=100" width="100px;" alt="Tomáš Hübelbauer"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://sixtwothree.org"><img src="https://avatars.githubusercontent.com/u/73866?v=4?s=100" width="100px;" alt="Jason Garber"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://nickreilingh.com/"><img src="https://avatars.githubusercontent.com/u/2458645?v=4?s=100" width="100px;" alt="Nick Reilingh"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="http://ebobby.org"><img src="https://avatars.githubusercontent.com/u/170356?v=4?s=100" width="100px;" alt="Francisco Soto"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://www.davidlj95.com"><img src="https://avatars.githubusercontent.com/u/8050648?v=4?s=100" width="100px;" alt="David LJ"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/RasmusWL"><img src="https://avatars.githubusercontent.com/u/1054041?v=4?s=100" width="100px;" alt="Rasmus Wriedt Larsen"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/brunodccarvalho"><img src="https://avatars.githubusercontent.com/u/24962950?v=4?s=100" width="100px;" alt="Bruno Carvalho"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/fupengl"><img src="https://avatars.githubusercontent.com/u/20211964?v=4?s=100" width="100px;" alt="傅鹏"/></a></td>
-      <td align="center" valign="top" width="0%"><a href="https://github.com/joel-hamilton"><img src="https://avatars.githubusercontent.com/u/12899024?v=4?s=100" width="100px;" alt="Joel Hamilton"/></a></td>
-    </tr>
-  </tbody>
-</table>
-
-<!-- markdownlint-restore -->
-<!-- prettier-ignore-end -->
-
-<!-- ALL-CONTRIBUTORS-LIST:END -->
diff --git a/src/context/context.ts b/src/context/context.ts
index abf9cf58f..f54b5ed6e 100644
--- a/src/context/context.ts
+++ b/src/context/context.ts
@@ -2,7 +2,7 @@ import { Drop } from '../drop/drop'
 import { __assign } from 'tslib'
 import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
 import { Scope } from './scope'
-import { isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync } from '../util'
+import { isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject } from '../util'
 
 type PropertyKey = string | number;
 
@@ -37,7 +37,7 @@ export class Context {
     this.sync = !!renderOptions.sync
     this.opts = opts
     this.globals = renderOptions.globals ?? opts.globals
-    this.environments = env
+    this.environments = isObject(env) ? env : Object(env)
     this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables
     this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly
   }
diff --git a/src/filters/array.ts b/src/filters/array.ts
index ed98b861f..9e94bf558 100644
--- a/src/filters/array.ts
+++ b/src/filters/array.ts
@@ -1,8 +1,8 @@
 import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, isArray, isNil, last as arrayLast, hasOwnProperty } from '../util'
-import { isTruthy } from '../render'
-import { FilterImpl } from '../template'
-import { Scope } from '../context'
-import { isComparable } from '../drop'
+import { equals, evalToken, isTruthy } from '../render'
+import { Value, FilterImpl } from '../template'
+import { Context, Scope } from '../context'
+import { Tokenizer } from '../parser'
 
 export const join = argumentsToValue((v: any[], arg: string) => toArray(v).join(arg === undefined ? ' ' : arg))
 export const last = argumentsToValue((v: any) => isArray(v) ? arrayLast(v) : '')
@@ -11,7 +11,7 @@ export const reverse = argumentsToValue((v: any[]) => [...toArray(v)].reverse())
 
 export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
   const values: [T, string | number][] = []
-  for (const item of toArray(toValue(arr))) {
+  for (const item of toArray(arr)) {
     values.push([
       item,
       property ? yield this.context._getFromScope(item, stringify(property).split('.'), false) : item
@@ -25,7 +25,6 @@ export function * sort<T> (this: FilterImpl, arr: T[], property?: string): Itera
 }
 
 export function sort_natural<T> (input: T[], property?: string) {
-  input = toValue(input)
   const propertyString = stringify(property)
   const compare = property === undefined
     ? caseInsensitiveCompare
@@ -37,7 +36,7 @@ export const size = (v: string | any[]) => (v && v.length) || 0
 
 export function * map (this: FilterImpl, arr: Scope[], property: string): IterableIterator<unknown> {
   const results = []
-  for (const item of toArray(toValue(arr))) {
+  for (const item of toArray(arr)) {
     results.push(yield this.context._getFromScope(item, stringify(property), false))
   }
   return results
@@ -45,7 +44,7 @@ export function * map (this: FilterImpl, arr: Scope[], property: string): Iterab
 
 export function * sum (this: FilterImpl, arr: Scope[], property?: string): IterableIterator<unknown> {
   let sum = 0
-  for (const item of toArray(toValue(arr))) {
+  for (const item of toArray(arr)) {
     const data = Number(property ? yield this.context._getFromScope(item, stringify(property), false) : item)
     sum += Number.isNaN(data) ? 0 : data
   }
@@ -53,20 +52,35 @@ export function * sum (this: FilterImpl, arr: Scope[], property?: string): Itera
 }
 
 export function compact<T> (this: FilterImpl, arr: T[]) {
-  arr = toValue(arr)
   return toArray(arr).filter(x => !isNil(toValue(x)))
 }
 
 export function concat<T1, T2> (v: T1[], arg: T2[] = []): (T1 | T2)[] {
-  v = toValue(v)
-  arg = toArray(arg).map(v => toValue(v))
-  return toArray(v).concat(arg)
+  return toArray(v).concat(toArray(arg))
 }
 
 export function push<T> (v: T[], arg: T): T[] {
   return concat(v, [arg])
 }
 
+export function unshift<T> (v: T[], arg: T): T[] {
+  const clone = [...toArray(v)]
+  clone.unshift(arg)
+  return clone
+}
+
+export function pop<T> (v: T[]): T[] {
+  const clone = [...toArray(v)]
+  clone.pop()
+  return clone
+}
+
+export function shift<T> (v: T[]): T[] {
+  const clone = [...toArray(v)]
+  clone.shift()
+  return clone
+}
+
 export function slice<T> (v: T[] | string, begin: number, length = 1): T[] | string {
   v = toValue(v)
   if (isNil(v)) return []
@@ -77,17 +91,58 @@ export function slice<T> (v: T[] | string, begin: number, length = 1): T[] | str
 
 export function * where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
   const values: unknown[] = []
-  arr = toArray(toValue(arr))
+  arr = toArray(arr)
+  const token = new Tokenizer(stringify(property)).readScopeValue()
   for (const item of arr) {
-    values.push(yield this.context._getFromScope(item, stringify(property).split('.'), false))
+    values.push(yield evalToken(token, new Context(item)))
   }
   return arr.filter((_, i) => {
     if (expected === undefined) return isTruthy(values[i], this.context)
-    if (isComparable(expected)) return expected.equals(values[i])
-    return values[i] === expected
+    return equals(values[i], expected)
   })
 }
 
+export function * group_by<T extends object> (arr: T[], property: string): IterableIterator<unknown> {
+  const map = new Map()
+  arr = toArray(arr)
+  const token = new Tokenizer(stringify(property)).readScopeValue()
+  for (const item of arr) {
+    const key = yield evalToken(token, new Context(item))
+    if (!map.has(key)) map.set(key, [])
+    map.get(key).push(item)
+  }
+  return [...map.entries()].map(([name, items]) => ({ name, items }))
+}
+
+export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
+  const map = new Map()
+  const keyTemplate = new Value(stringify(exp), this.liquid)
+  for (const item of toArray(arr)) {
+    const key = yield keyTemplate.value(new Context({ [itemName]: item }))
+    if (!map.has(key)) map.set(key, [])
+    map.get(key).push(item)
+  }
+  return [...map.entries()].map(([name, items]) => ({ name, items }))
+}
+
+export function * find<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
+  const token = new Tokenizer(stringify(property)).readScopeValue()
+  for (const item of toArray(arr)) {
+    const value = yield evalToken(token, new Context(item))
+    if (equals(value, expected)) return item
+  }
+  return null
+}
+
+export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
+  const predicate = new Value(stringify(exp), this.liquid)
+  for (const item of toArray(arr)) {
+    const value = yield predicate.value(new Context({ [itemName]: item }))
+    if (value) return item
+  }
+  return null
+}
+
 export function uniq<T> (arr: T[]): T[] {
   arr = toValue(arr)
   const u = {}
diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts
index 5adc8d87f..cea86d12a 100644
--- a/src/parser/tokenizer.ts
+++ b/src/parser/tokenizer.ts
@@ -17,7 +17,7 @@ export class Tokenizer {
     public input: string,
     operators: Operators = defaultOptions.operators,
     public file?: string,
-    private range?: [number, number]
+    range?: [number, number]
   ) {
     this.p = range ? range[0] : 0
     this.N = range ? range[1] : input.length
@@ -249,6 +249,11 @@ export class Tokenizer {
     return new IdentifierToken(this.input, begin, this.p, this.file)
   }
 
+  readNonEmptyIdentifier (): IdentifierToken | undefined {
+    const id = this.readIdentifier()
+    return id.size() ? id : undefined
+  }
+
   readTagName (): string {
     this.skipBlank()
     // Handle inline comment tags
@@ -269,8 +274,8 @@ export class Tokenizer {
     this.skipBlank()
     if (this.peek() === ',') ++this.p
     const begin = this.p
-    const name = this.readIdentifier()
-    if (!name.size()) return
+    const name = this.readNonEmptyIdentifier()
+    if (!name) return
     let value
 
     this.skipBlank()
@@ -306,6 +311,20 @@ export class Tokenizer {
     this.skipBlank()
     const begin = this.p
     const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber()
+    const props = this.readProperties(!variable)
+    if (!props.length) return variable
+    return new PropertyAccessToken(variable, props, this.input, begin, this.p)
+  }
+
+  readScopeValue (): ValueToken | undefined {
+    this.skipBlank()
+    const begin = this.p
+    const props = this.readProperties()
+    if (!props.length) return undefined
+    return new PropertyAccessToken(undefined, props, this.input, begin, this.p)
+  }
+
+  private readProperties (isBegin = true): (ValueToken | IdentifierToken)[] {
     const props: (ValueToken | IdentifierToken)[] = []
     while (true) {
       if (this.peek() === '[') {
@@ -315,24 +334,23 @@ export class Tokenizer {
         props.push(prop)
         continue
       }
-      if (!variable && !props.length) {
-        const prop = this.readIdentifier()
-        if (prop.size()) {
+      if (isBegin && !props.length) {
+        const prop = this.readNonEmptyIdentifier()
+        if (prop) {
           props.push(prop)
           continue
         }
       }
       if (this.peek() === '.' && this.peek(1) !== '.') { // skip range syntax
         this.p++
-        const prop = this.readIdentifier()
-        if (!prop.size()) break
+        const prop = this.readNonEmptyIdentifier()
+        if (!prop) break
         props.push(prop)
         continue
       }
       break
     }
-    if (!props.length) return variable
-    return new PropertyAccessToken(variable, props, this.input, begin, this.p)
+    return props
   }
 
   readNumber (): NumberToken | undefined {
diff --git a/src/render/expression.ts b/src/render/expression.ts
index 26dbef424..fdb93da57 100644
--- a/src/render/expression.ts
+++ b/src/render/expression.ts
@@ -43,12 +43,12 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f
 
 function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
   const props: string[] = []
-  const variable = yield evalToken(token.variable, ctx, lenient)
   for (const prop of token.props) {
     props.push((yield evalToken(prop, ctx, false)) as unknown as string)
   }
   try {
     if (token.variable) {
+      const variable = yield evalToken(token.variable, ctx, lenient)
       return yield ctx._getFromScope(variable, props)
     } else {
       return yield ctx._get(props)
diff --git a/src/render/operator.ts b/src/render/operator.ts
index d711a0d5d..9e718cd7b 100644
--- a/src/render/operator.ts
+++ b/src/render/operator.ts
@@ -10,8 +10,8 @@ export type OperatorHandler = UnaryOperatorHandler | BinaryOperatorHandler;
 export type Operators = Record<string, OperatorHandler>
 
 export const defaultOperators: Operators = {
-  '==': equal,
-  '!=': (l: any, r: any) => !equal(l, r),
+  '==': equals,
+  '!=': (l: any, r: any) => !equals(l, r),
   '>': (l: any, r: any) => {
     if (isComparable(l)) return l.gt(r)
     if (isComparable(r)) return r.lt(l)
@@ -34,7 +34,7 @@ export const defaultOperators: Operators = {
   },
   'contains': (l: any, r: any) => {
     l = toValue(l)
-    if (isArray(l)) return l.some((i) => equal(i, r))
+    if (isArray(l)) return l.some((i) => equals(i, r))
     if (isFunction(l?.indexOf)) return l.indexOf(toValue(r)) > -1
     return false
   },
@@ -43,18 +43,18 @@ export const defaultOperators: Operators = {
   'or': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) || isTruthy(toValue(r), ctx)
 }
 
-function equal (lhs: any, rhs: any): boolean {
+export function equals (lhs: any, rhs: any): boolean {
   if (isComparable(lhs)) return lhs.equals(rhs)
   if (isComparable(rhs)) return rhs.equals(lhs)
   lhs = toValue(lhs)
   rhs = toValue(rhs)
   if (isArray(lhs)) {
-    return isArray(rhs) && arrayEqual(lhs, rhs)
+    return isArray(rhs) && arrayEquals(lhs, rhs)
   }
   return lhs === rhs
 }
 
-function arrayEqual (lhs: any[], rhs: any[]): boolean {
+function arrayEquals (lhs: any[], rhs: any[]): boolean {
   if (lhs.length !== rhs.length) return false
-  return !lhs.some((value, i) => !equal(value, rhs[i]))
+  return !lhs.some((value, i) => !equals(value, rhs[i]))
 }
diff --git a/src/tags/for.ts b/src/tags/for.ts
index 2e65ed810..4d1f2f3bd 100644
--- a/src/tags/for.ts
+++ b/src/tags/for.ts
@@ -1,5 +1,5 @@
 import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
-import { toEnumerable } from '../util/collection'
+import { toEnumerable } from '../util'
 import { ForloopDrop } from '../drop/forloop-drop'
 
 const MODIFIERS = ['offset', 'limit', 'reversed']
diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts
index f0f6062eb..59e167a49 100644
--- a/src/tags/tablerow.ts
+++ b/src/tags/tablerow.ts
@@ -1,4 +1,4 @@
-import { toEnumerable } from '../util/collection'
+import { toEnumerable } from '../util'
 import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
 import { TablerowloopDrop } from '../drop/tablerowloop-drop'
 
diff --git a/src/util/collection.ts b/src/util/collection.ts
deleted file mode 100644
index 43b7bfb34..000000000
--- a/src/util/collection.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { isNil, isString, isObject, isArray, isIterable, toValue } from './underscore'
-
-export function toEnumerable<T = unknown> (val: any): T[] {
-  val = toValue(val)
-  if (isArray(val)) return val
-  if (isString(val) && val.length > 0) return [val] as unknown as T[]
-  if (isIterable(val)) return Array.from(val)
-  if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) as unknown as T[]
-  return []
-}
-
-export function toArray (val: any) {
-  if (isNil(val)) return []
-  if (isArray(val)) return val
-  return [ val ]
-}
diff --git a/src/util/index.ts b/src/util/index.ts
index 5a6152c3d..8b57690b6 100644
--- a/src/util/index.ts
+++ b/src/util/index.ts
@@ -6,7 +6,6 @@ export * from './underscore'
 export * from './operator-trie'
 export * from './type-guards'
 export * from './async'
-export * from './collection'
 export * from './strftime'
 export * from './liquid-date'
 export * from './timezone-date'
diff --git a/src/util/underscore.ts b/src/util/underscore.ts
index 08168df7b..c851ef6d0 100644
--- a/src/util/underscore.ts
+++ b/src/util/underscore.ts
@@ -46,6 +46,22 @@ export function stringify (value: any): string {
   return String(value)
 }
 
+export function toEnumerable<T = unknown> (val: any): T[] {
+  val = toValue(val)
+  if (isArray(val)) return val
+  if (isString(val) && val.length > 0) return [val] as unknown as T[]
+  if (isIterable(val)) return Array.from(val)
+  if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) as unknown as T[]
+  return []
+}
+
+export function toArray (val: any) {
+  val = toValue(val)
+  if (isNil(val)) return []
+  if (isArray(val)) return val
+  return [ val ]
+}
+
 export function toValue (value: any): any {
   return (value instanceof Drop && isFunction(value.valueOf)) ? value.valueOf() : value
 }


Test patch:
diff --git a/test/integration/filters/array.spec.ts b/test/integration/filters/array.spec.ts
index b1d50a6d2..a5901aaf6 100644
--- a/test/integration/filters/array.spec.ts
+++ b/test/integration/filters/array.spec.ts
@@ -158,10 +158,53 @@ describe('filters/array', function () {
       await test('{{ undefinedValue | push: arg | join: "," }}', scope, 'foo')
       await test('{{ nullValue | push: arg | join: "," }}', scope, 'foo')
     })
-    it('should ignore nil right value', async () => {
-      const scope = { nullValue: null }
-      await test('{{ nullValue | push | join: "," }}', scope, '')
-      await test('{{ nullValue | push: nil | join: "," }}', scope, '')
+    it('should support nil right value', async () => {
+      const scope = { nullValue: [] }
+      await test('{{ nullValue | push | size }}', scope, '1')
+      await test('{{ nullValue | push: nil | size }}', scope, '1')
+    })
+  })
+
+  describe('pop', () => {
+    it('should support pop', async () => {
+      const scope = { val: ['hey', 'you'] }
+      await test('{{ val | pop | join: "," }}', scope, 'hey')
+    })
+    it('should not change original array', async () => {
+      const scope = { val: ['hey', 'you'] }
+      await test('{{ val | pop | join: "," }} {{ val| join: "," }}', scope, 'hey hey,you')
+    })
+    it('should support nil left value', async () => {
+      await test('{{ notDefined | pop | join: "," }}', {}, '')
+    })
+  })
+
+  describe('unshift', () => {
+    it('should support unshift', async () => {
+      const scope = { val: ['you'] }
+      await test('{{ val | unshift: "hey" | join: ", " }}', scope, 'hey, you')
+    })
+    it('should not change original array', async () => {
+      const scope = { val: ['you'] }
+      await test('{{ val | unshift: "hey" | join: "," }} {{ val | join: "," }}', scope, 'hey,you you')
+    })
+    it('should support nil right value', async () => {
+      const scope = { val: [] }
+      await test('{{ val | unshift: nil | size }}', scope, '1')
+    })
+  })
+
+  describe('shift', () => {
+    it('should support pop', async () => {
+      const scope = { val: ['hey', 'you'] }
+      await test('{{ val | shift }}', scope, 'you')
+    })
+    it('should not change original array', async () => {
+      const scope = { val: ['hey', 'you'] }
+      await test('{{ val | shift }} {{ val | join: ","}}', scope, 'you hey,you')
+    })
+    it('should support nil left value', async () => {
+      await test('{{ notDefined | pop }}', {}, '')
     })
   })
 
@@ -404,5 +447,109 @@ describe('filters/array', function () {
       await test('{{obj | where: "foo", "FOO" }}', scope, '[object Object]')
       await test('{{obj | where: "foo", "BAR" }}', scope, '')
     })
+    it('should support nested dynamic property', function () {
+      const products = [
+        { meta: { details: { class: 'A' } }, order: 1 },
+        { meta: { details: { class: 'B' } }, order: 2 },
+        { meta: { details: { class: 'B' } }, order: 3 }
+      ]
+      return test(`{% assign selected = products | where: 'meta.details["class"]', exp %}
+        {% for item in selected -%}
+        - {{ item.order }}
+        {% endfor %}`, { products, exp: 'B' }, `
+        - 2
+        - 3
+        `)
+    })
+    it('should support escape in property', function () {
+      const array = [
+        { foo: { "'": 'foo' }, order: 1 },
+        { foo: { "'": 'foo' }, order: 2 },
+        { foo: { "'": 'bar' }, order: 3 }
+      ]
+      return test(`{% assign selected = array | where: 'foo["\\'"]', "foo" %}
+        {% for item in selected -%}
+        - {{ item.order }}
+        {% endfor %}`, { array }, `
+        - 1
+        - 2
+        `)
+    })
+  })
+  describe('group_by', function () {
+    const members = [
+      { graduation_year: 2003, name: 'Jay' },
+      { graduation_year: 2003, name: 'John' },
+      { graduation_year: 2004, name: 'Jack' }
+    ]
+    it('should support group by property', function () {
+      const expected = [{
+        name: 2003,
+        items: [
+          { graduation_year: 2003, name: 'Jay' },
+          { graduation_year: 2003, name: 'John' }
+        ]
+      }, {
+        name: 2004,
+        items: [
+          { graduation_year: 2004, name: 'Jack' }
+        ]
+      }]
+      return test(
+        `{{ members | group_by: "graduation_year" | json}}`,
+        { members },
+        JSON.stringify(expected))
+    })
+  })
+  describe('group_by_exp', function () {
+    const members = [
+      { graduation_year: 2013, name: 'Jay' },
+      { graduation_year: 2014, name: 'John' },
+      { graduation_year: 2009, name: 'Jack' }
+    ]
+    it('should support group by expression', function () {
+      const expected = [{
+        name: '201',
+        items: [
+          { graduation_year: 2013, name: 'Jay' },
+          { graduation_year: 2014, name: 'John' }
+        ]
+      }, {
+        name: '200',
+        items: [
+          { graduation_year: 2009, name: 'Jack' }
+        ]
+      }]
+      return test(
+        `{{ members | group_by_exp: "item", "item.graduation_year | truncate: 3, ''" | json}}`,
+        { members },
+        JSON.stringify(expected))
+    })
+  })
+  describe('find', function () {
+    const members = [
+      { graduation_year: 2013, name: 'Jay' },
+      { graduation_year: 2014, name: 'John' },
+      { graduation_year: 2014, name: 'Jack' }
+    ]
+    it('should support find by property', function () {
+      return test(
+        `{{ members | find: "graduation_year", 2014 | json }}`,
+        { members },
+        `{"graduation_year":2014,"name":"John"}`)
+    })
+  })
+  describe('find_exp', function () {
+    const members = [
+      { graduation_year: 2013, name: 'Jay' },
+      { graduation_year: 2014, name: 'John' },
+      { graduation_year: 2014, name: 'Jack' }
+    ]
+    it('should support find by expression', function () {
+      return test(
+        `{{ members | find_exp: "item", "item.graduation_year == 2014" | json }}`,
+        { members },
+        `{"graduation_year":2014,"name":"John"}`)
+    })
   })
 })
diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts
index 0d09b8c1a..0cfcd31a6 100644
--- a/test/integration/tags/for.spec.ts
+++ b/test/integration/tags/for.spec.ts
@@ -63,7 +63,7 @@ describe('tags/for', function () {
     const engine = new Liquid({ strictVariables: true })
     const src = '{% assign hello = "hello,world" | split: "," | concat: null %}{% for i in hello %}{{ i }},{% endfor %}'
     const html = await engine.parseAndRender(src, scope)
-    return expect(html).toBe('hello,world,,')
+    return expect(html).toBe('hello,world,')
   })
   describe('illegal', function () {
     it('should reject when for not closed', function () {