diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml index 3ee44dcad2..959ea56368 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 diff --git a/docs/source/filters/find.md b/docs/source/filters/find.md new file mode 100644 index 0000000000..3d2a00c7d7 --- /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 0000000000..c56a18bef8 --- /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 0000000000..2540fa1b62 --- /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 0000000000..bd4bb481cf --- /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 f7908298fd..196909c7b5 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 24f0b6591b..0ed7480c2a 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, push, pop, shift, unshift +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/where.md b/docs/source/filters/where.md index b2bc164957..2054fac191 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 4e372e01a6..036ea54169 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 0000000000..7bcbd03ee5 --- /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 < 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" }} +{{ "" | raw }} +``` + +Output +```text +1 < 2 + +``` + +## 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 4c91a4a0a8..db4124c93c 100644 --- a/docs/source/tutorials/options.md +++ b/docs/source/tutorials/options.md @@ -102,6 +102,14 @@ Before 2.0.1, extname 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 0000000000..baebe69b8c --- /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 0000000000..60a55c70e8 --- /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 0000000000..2ce1b08ac8 --- /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 0000000000..a68aaaee64 --- /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 7089b46f2e..14f1647334 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 848c2e68a4..5938d40153 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, push, pop, shift, unshift +数组 | 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/where.md b/docs/source/zh-cn/filters/where.md index 592ff58225..07b8583174 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 d86356c644..3dff7367a5 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 0000000000..042bcc34e1 --- /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 < 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" }} +{{ "" | raw }} +``` + +输出 +```text +1 < 2 + +``` + +## 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 c73ed74566..a69442039a 100644 --- a/docs/source/zh-cn/tutorials/options.md +++ b/docs/source/zh-cn/tutorials/options.md @@ -100,6 +100,14 @@ LiquidJS 把这个选项默认值设为 true 以兼容于 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 把这个选项默认值设为 true 以兼容于 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 ba5b764bd9..1718428eb5 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 9333eec192..82dfc0a597 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/src/context/context.ts b/src/context/context.ts index abf9cf58f2..f54b5ed6ea 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 d34c09923e..9e94bf5587 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) : '') @@ -92,16 +92,57 @@ export function slice (v: T[] | string, begin: number, length = 1): T[] | str export function * where (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator { const values: unknown[] = [] 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 (arr: T[], property: string): IterableIterator { + 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 (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + 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 (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator { + 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 (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { + 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 (arr: T[]): T[] { arr = toValue(arr) const u = {} diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 5adc8d87f9..cea86d12a3 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 26dbef424e..fdb93da574 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 { 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 d711a0d5d8..9e718cd7b7 100644 --- a/src/render/operator.ts +++ b/src/render/operator.ts @@ -10,8 +10,8 @@ export type OperatorHandler = UnaryOperatorHandler | BinaryOperatorHandler; export type Operators = Record 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/test/integration/filters/array.spec.ts b/test/integration/filters/array.spec.ts index ed2c7bd45c..a5901aaf6a 100644 --- a/test/integration/filters/array.spec.ts +++ b/test/integration/filters/array.spec.ts @@ -447,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"}`) + }) }) })