Repository: harttle/liquidjs
Pull request #691 (2024-05-06 13:02:52)

feat: jsonify, inspect, to_integer, normalize_whitespace filters, #443


Patch:
diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml
index c609bb196..ac0631b11 100644
--- a/docs/source/_data/sidebar.yml
+++ b/docs/source/_data/sidebar.yml
@@ -47,14 +47,17 @@ filters:
   floor: floor.html
   group_by: group_by.html
   group_by_exp: group_by_exp.html
+  inspect: inspect.html
   join: join.html
   json: json.html
+  jsonify: jsonify.html
   last: last.html
   lstrip: lstrip.html
   map: map.html
   minus: minus.html
   modulo: modulo.html
   newline_to_br: newline_to_br.html
+  normalize_whitespace: normalize_whitespace.html
   plus: plus.html
   pop: pop.html
   push: push.html
@@ -80,6 +83,7 @@ filters:
   strip_newlines: strip_newlines.html
   sum: sum.html
   times: times.html
+  to_integer: to_integer.html
   truncate: truncate.html
   truncatewords: truncatewords.html
   uniq: uniq.html
diff --git a/docs/source/filters/inspect.md b/docs/source/filters/inspect.md
new file mode 100644
index 000000000..7abc85619
--- /dev/null
+++ b/docs/source/filters/inspect.md
@@ -0,0 +1,42 @@
+---
+title: inspect
+---
+
+{% since %}v10.13.0{% endsince %}
+
+Similar with `json`, but `inspect` allows cyclic structure. For the scope below:
+
+```
+const foo = {
+    bar: 'BAR'
+}
+foo.foo = foo
+const scope = { foo }
+```
+
+Input
+```liquid
+{% foo | inspect %}
+```
+
+Output
+```text
+{"bar":"BAR","foo":"[Circular]"}
+```
+
+## Formatting
+
+An additional `space` argument can be specified for the indent width.
+
+Input
+```liquid
+{{ foo | inspect: 4 }}
+```
+
+Output
+```text
+{
+    "bar": "BAR",
+    "foo": "[Circular]"
+}
+```
diff --git a/docs/source/filters/jsonify.md b/docs/source/filters/jsonify.md
new file mode 100644
index 000000000..dd2ca819d
--- /dev/null
+++ b/docs/source/filters/jsonify.md
@@ -0,0 +1,9 @@
+---
+title: jsonify
+---
+
+{% since %}v10.13.0{% endsince %}
+
+See [json][json].
+
+[json]: /filters/json.html
diff --git a/docs/source/filters/normalize_whitespace.md b/docs/source/filters/normalize_whitespace.md
new file mode 100644
index 000000000..bafc290b2
--- /dev/null
+++ b/docs/source/filters/normalize_whitespace.md
@@ -0,0 +1,17 @@
+---
+title: normalize_whitespace
+---
+
+{% since %}v10.13.0{% endsince %}
+
+Replace any occurrence of whitespace with a single space.
+
+Input
+```liquid
+{{ "a \n b" | normalize_whitespace }}
+```
+
+Output
+```html
+a b
+```
diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md
index 03559308b..a5e11d3b8 100644
--- a/docs/source/filters/overview.md
+++ b/docs/source/filters/overview.md
@@ -10,10 +10,10 @@ There's 40+ filters supported by LiquidJS. These filters can be categorized into
 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
+String | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last,remove, remove_first, remove_last, truncate, truncatewords, normalize_whitespace
 HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br
 Array | slice, map, sort, sort_natural, uniq, where, where_exp, 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
+Misc | default, json, jsonify, inspect, raw, to_integer
 
 [shopify/liquid]: https://github.com/Shopify/liquid
diff --git a/docs/source/filters/to_integer.md b/docs/source/filters/to_integer.md
new file mode 100644
index 000000000..d635f8556
--- /dev/null
+++ b/docs/source/filters/to_integer.md
@@ -0,0 +1,17 @@
+---
+title: to_integer
+---
+
+{% since %}v10.13.0{% endsince %}
+
+Convert values to number.
+
+Input
+```liquid
+{{ "123" | to_integer | json }}
+```
+
+Output
+```text
+123
+```
diff --git a/docs/source/tutorials/differences.md b/docs/source/tutorials/differences.md
index 91a6b43dd..e6bb6cfcb 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`, see below for details.
+* **Additional tags and filters** like `layout` and `json`, `inspect`, `where_exp`, `group_by`, etc., see below for details.
 
 ## Differences
 
@@ -31,7 +31,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
 * 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 more tags/filters than [the Liquid language][liquid]:
     * LiquidJS-defined tags: [layout][layout], [render][render] and corresponding `block` tag.
-    * LiquidJS-defined filters: [json][json].
+    * LiquidJS-defined filters: [json][json], group_by, group_by_exp, where_exp, jsonify, inspect, etc.
     * 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].
 * Some tags/filters behave differently: [date][date] filter.
diff --git a/docs/source/zh-cn/filters/inspect.md b/docs/source/zh-cn/filters/inspect.md
new file mode 100644
index 000000000..e59dff12f
--- /dev/null
+++ b/docs/source/zh-cn/filters/inspect.md
@@ -0,0 +1,42 @@
+---
+title: inspect
+---
+
+{% since %}v10.13.0{% endsince %}
+
+类似于 `json`，但可以处理循环引用的情况。例如对于上下文：
+
+```
+const foo = {
+    bar: 'BAR'
+}
+foo.foo = foo
+const scope = { foo }
+```
+
+输入
+```liquid
+{% foo | inspect %}
+```
+
+输出
+```text
+{"bar":"BAR","foo":"[Circular]"}
+```
+
+## 格式化
+
+可以指定一个 `space` 参数来缩进长度。
+
+输入
+```liquid
+{{ foo | inspect: 4 }}
+```
+
+输出
+```text
+{
+    "bar": "BAR",
+    "foo": "[Circular]"
+}
+```
diff --git a/docs/source/zh-cn/filters/json.md b/docs/source/zh-cn/filters/json.md
index 14f164733..3fc6e71f7 100644
--- a/docs/source/zh-cn/filters/json.md
+++ b/docs/source/zh-cn/filters/json.md
@@ -23,13 +23,13 @@ title: json
 
 可以指定一个 `space` 参数来格式化 JSON。
 
-Input
+输入
 ```liquid
 {% assign arr = "foo bar coo" | split: " " %}
 {{ arr | json: 4 }}
 ```
 
-Output
+输出
 ```text
 [
     "foo",
diff --git a/docs/source/zh-cn/filters/jsonify.md b/docs/source/zh-cn/filters/jsonify.md
new file mode 100644
index 000000000..55ff26ed6
--- /dev/null
+++ b/docs/source/zh-cn/filters/jsonify.md
@@ -0,0 +1,9 @@
+---
+title: jsonify
+---
+
+{% since %}v10.13.0{% endsince %}
+
+见 [json][json]。
+
+[json]: ./json.html
diff --git a/docs/source/zh-cn/filters/normalize_whitespace.md b/docs/source/zh-cn/filters/normalize_whitespace.md
new file mode 100644
index 000000000..db203419b
--- /dev/null
+++ b/docs/source/zh-cn/filters/normalize_whitespace.md
@@ -0,0 +1,17 @@
+---
+title: normalize_whitespace
+---
+
+{% since %}v10.13.0{% endsince %}
+
+把连续的空白字符替换为单个空格。
+
+输入
+```liquid
+{{ "a \n b" | normalize_whitespace }}
+```
+
+输出
+```html
+a b
+```
diff --git a/docs/source/zh-cn/filters/overview.md b/docs/source/zh-cn/filters/overview.md
index 8effee007..ce13a9e59 100644
--- a/docs/source/zh-cn/filters/overview.md
+++ b/docs/source/zh-cn/filters/overview.md
@@ -10,10 +10,10 @@ 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
+字符串 | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, strip_newlines, split, replace, replace_first, replace_last, remove, remove_first, remove_last, truncate, truncatewords, normalize_whitespace
 HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_br
 数组 | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
 日期 | date
-其他 | default, json
+其他 | default, json, jsonify, inspect, raw, to_integer
 
 [shopify/liquid]: https://github.com/Shopify/liquid
diff --git a/docs/source/zh-cn/filters/to_integer.md b/docs/source/zh-cn/filters/to_integer.md
new file mode 100644
index 000000000..e79c879ec
--- /dev/null
+++ b/docs/source/zh-cn/filters/to_integer.md
@@ -0,0 +1,17 @@
+---
+title: to_integer
+---
+
+{% since %}v10.13.0{% endsince %}
+
+转换为数字类型。
+
+输入
+```liquid
+{{ "123" | to_integer | json }}
+```
+
+输出
+```text
+123
+```
diff --git a/src/filters/index.ts b/src/filters/index.ts
index 1f296c726..caf040c9b 100644
--- a/src/filters/index.ts
+++ b/src/filters/index.ts
@@ -4,7 +4,7 @@ import * as urlFilters from './url'
 import * as arrayFilters from './array'
 import * as dateFilters from './date'
 import * as stringFilters from './string'
-import { Default, json, raw } from './misc'
+import misc from './misc'
 import { FilterImplOptions } from '../template'
 
 export const filters: Record<string, FilterImplOptions> = {
@@ -14,7 +14,5 @@ export const filters: Record<string, FilterImplOptions> = {
   ...arrayFilters,
   ...dateFilters,
   ...stringFilters,
-  json,
-  raw,
-  default: Default
+  ...misc
 }
diff --git a/src/filters/misc.ts b/src/filters/misc.ts
index 806c58ea1..57ed57812 100644
--- a/src/filters/misc.ts
+++ b/src/filters/misc.ts
@@ -2,18 +2,43 @@ import { isFalsy } from '../render/boolean'
 import { identify, isArray, isString, toValue } from '../util/underscore'
 import { FilterImpl } from '../template'
 
-export function Default<T1 extends boolean, T2> (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 {
+function defaultFilter<T1 extends boolean, T2> (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 {
   value = toValue(value)
   if (isArray(value) || isString(value)) return value.length ? value : defaultValue
   if (value === false && (new Map(args)).get('allow_false')) return false as T1
   return isFalsy(value, this.context) ? defaultValue : value
 }
 
-export function json (value: any, space = 0) {
+function json (value: any, space = 0) {
   return JSON.stringify(value, null, space)
 }
 
-export const raw = {
+function inspect (value: any, space = 0) {
+  const ancestors: object[] = []
+  return JSON.stringify(value, function (this: unknown, _key: unknown, value: any) {
+    if (typeof value !== 'object' || value === null) return value
+    // `this` is the object that value is contained in, i.e., its direct parent.
+    while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
+    if (ancestors.includes(value)) return '[Circular]'
+    ancestors.push(value)
+    return value
+  }, space)
+}
+
+function to_integer (value: any) {
+  return Number(value)
+}
+
+const raw = {
   raw: true,
   handler: identify
 }
+
+export default {
+  default: defaultFilter,
+  raw,
+  jsonify: json,
+  to_integer,
+  json,
+  inspect
+}
diff --git a/src/filters/string.ts b/src/filters/string.ts
index 2a1013581..1bdc315d8 100644
--- a/src/filters/string.ts
+++ b/src/filters/string.ts
@@ -112,3 +112,8 @@ export function truncatewords (v: string, words = 15, o = '...') {
   if (arr.length >= words) ret += o
   return ret
 }
+
+export function normalize_whitespace (v: string) {
+  v = stringify(v)
+  return v.replace(/\s+/g, ' ')
+}
diff --git a/src/liquid-options.ts b/src/liquid-options.ts
index c22070299..89223bbf9 100644
--- a/src/liquid-options.ts
+++ b/src/liquid-options.ts
@@ -3,7 +3,7 @@ import { LRU, LiquidCache } from './cache'
 import { FS, LookupType } from './fs'
 import * as fs from './fs/fs-impl'
 import { defaultOperators, Operators } from './render'
-import { json } from './filters/misc'
+import misc from './filters/misc'
 import { escape } from './filters/html'
 
 type OutputEscape = (value: any) => string
@@ -193,7 +193,7 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
 
 function getOutputEscapeFunction (nameOrFunction: OutputEscapeOption): OutputEscape {
   if (nameOrFunction === 'escape') return escape
-  if (nameOrFunction === 'json') return json
+  if (nameOrFunction === 'json') return misc.json
   assert(isFunction(nameOrFunction), '`outputEscape` need to be of type string or function')
   return nameOrFunction
 }


Test patch:
diff --git a/test/integration/filters/misc.spec.ts b/test/integration/filters/misc.spec.ts
index bc6f92117..f60b26015 100644
--- a/test/integration/filters/misc.spec.ts
+++ b/test/integration/filters/misc.spec.ts
@@ -30,4 +30,33 @@ describe('filters/object', function () {
       expect(liquid.parseAndRenderSync('{{obj | json: 4}}', scope)).toBe(result)
     })
   })
+  describe('jsonify', function () {
+    it('should stringify string', async () => expect(await liquid.parseAndRender('{{"foo" | jsonify}}')).toBe('"foo"'))
+  })
+  describe('inspect', function () {
+    it('should inspect string', async () => expect(await liquid.parseAndRender('{{"foo" | inspect}}')).toBe('"foo"'))
+    it('should inspect object', () => {
+      const text = '{{foo | inspect}}'
+      const foo = { bar: 'bar' }
+      const expected = '{"bar":"bar"}'
+      return expect(liquid.parseAndRenderSync(text, { foo })).toBe(expected)
+    })
+    it('should inspect cyclic object', () => {
+      const text = '{{foo | inspect}}'
+      const foo: any = { bar: 'bar' }
+      foo.foo = foo
+      const expected = '{"bar":"bar","foo":"[Circular]"}'
+      return expect(liquid.parseAndRenderSync(text, { foo })).toBe(expected)
+    })
+    it('should support space argument', () => {
+      const text = '{{foo | inspect: 4}}'
+      const foo: any = { bar: 'bar' }
+      foo.foo = foo
+      const expected = '{\n    "bar": "bar",\n    "foo": "[Circular]"\n}'
+      return expect(liquid.parseAndRenderSync(text, { foo })).toBe(expected)
+    })
+  })
+  describe('to_integer', function () {
+    it('should stringify string', () => expect(liquid.parseAndRenderSync('{{ "123" | to_integer | json }}')).toBe('123'))
+  })
 })
diff --git a/test/integration/filters/string.spec.ts b/test/integration/filters/string.spec.ts
index bc54e07ce..3cb494da5 100644
--- a/test/integration/filters/string.spec.ts
+++ b/test/integration/filters/string.spec.ts
@@ -1,6 +1,8 @@
 import { test } from '../../stub/render'
+import { Liquid } from '../../../src/liquid'
 
 describe('filters/string', function () {
+  const liquid = new Liquid()
   describe('append', function () {
     it('should return "-3abc" for -3, "abc"',
       () => test('{{ -3 | append: "abc" }}', '-3abc'))
@@ -228,4 +230,12 @@ describe('filters/string', function () {
         'Take my protein pills and put my helmet on')
     })
   })
+  describe('normalize_whitespace', () => {
+    it('should replace " \n " with " "', () => {
+      expect(liquid.parseAndRenderSync('{{ "a \n b" | normalize_whitespace }}')).toEqual('a b')
+    })
+    it('should replace multiple occurrences', () => {
+      expect(liquid.parseAndRenderSync('{{ "a \n b  c" | normalize_whitespace }}')).toEqual('a b c')
+    })
+  })
 })