Skip to content

Commit

Permalink
feat: automatic output escaping, closes #500
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Apr 21, 2022
1 parent e69a510 commit f88490c
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/source/_data/sidebar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ filters:
newline_to_br: newline_to_br.html
plus: plus.html
prepend: prepend.html
raw: raw.html
remove: remove.html
remove_first: remove_first.html
replace: replace.html
Expand Down
2 changes: 1 addition & 1 deletion docs/source/filters/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ String | append, prepend, capitalize, upcase, downcase, strip, lstrip, rstrip, s
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
Date | date
Misc | default, json
Misc | default, json, raw

[shopify/liquid]: https://github.com/Shopify/liquid
51 changes: 51 additions & 0 deletions docs/source/filters/raw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: raw
---

{% since %}v9.37.0{% endsince %}

Liquid filter that directly returns the value of the variable. Useful when [outputEscape](/api/interfaces/liquid_options_.liquidoptions.html#Optional-outputEscape) is set.

{% note info Auto escape %}
By default `outputEscape` is not set. That means LiquidJS output is not escaped by default, thus `raw` filter is not useful until `outputEscape` is set.
{% endnote %}

Input (`outputEscape` not set)
```liquid
{{ "<" }}
```

Output
```text
<
```

Input (`outputEscape="escape"`)
```liquid
{{ "<" }}
```

Output
```text
&lt;
```

Input (`outputEscape="json"`)
```liquid
{{ "<" }}
```

Output
```text
"<"
```

Input (`outputEscape="escape"`)
```liquid
{{ "<" | raw }}
```

Output
```text
<
```
52 changes: 52 additions & 0 deletions docs/source/zh-cn/filters/raw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
title: raw
---

{% since %}v9.37.0{% endsince %}

直接返回变量的值。配合 [outputEscape](/api/interfaces/liquid_options_.liquidoptions.html#Optional-outputEscape) 参数使用。

{% note info 自动转义 %}
默认情况下 `outputEscape``undefined`,这意味着 LiquidJS 输出不会默认转义,因此这时使用 `raw` 没有意义。
{% endnote %}

输入(未设置 `outputEscape`
```liquid
{{ "<" }}
```

输出
```text
<
```

输入(`outputEscape="escape"`
```liquid
{{ "<" }}
```

输出
```text
&lt;
```

输入(`outputEscape="json"`
```liquid
{{ "<" }}
```

输出
```text
"<"
```

输入(`outputEscape="escape"`
```liquid
{{ "<" | raw }}
```

输出
```text
<
```

4 changes: 3 additions & 1 deletion src/builtin/filters/misc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isFalsy } from '../../render/boolean'
import { isArray, isString, toValue } from '../../util/underscore'
import { identify, isArray, isString, toValue } from '../../util/underscore'
import { FilterImpl } from '../../template/filter/filter-impl'

export function Default<T1 extends boolean, T2> (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 {
Expand All @@ -12,3 +12,5 @@ export function Default<T1 extends boolean, T2> (this: FilterImpl, value: T1, de
export function json (value: any) {
return JSON.stringify(value)
}

export const raw = identify
31 changes: 28 additions & 3 deletions src/liquid-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as _ from './util/underscore'
import { snakeCase, forOwn, isArray, isString, isFunction } from './util/underscore'
import { Template } from './template/template'
import { Cache } from './cache/cache'
import { LRU } from './cache/lru'
Expand All @@ -7,6 +7,16 @@ import * as fs from './fs/node'
import { defaultOperators, Operators } from './render/operator'
import { createTrie, Trie } from './util/operator-trie'
import { Thenable } from './util/async'
import * as builtinFilters from './builtin/filters'
import { assert, FilterImplOptions } from './types'

const filters = new Map()
forOwn(builtinFilters, (conf: FilterImplOptions, name: string) => {
filters.set(snakeCase(name), conf)
})

type OutputEscape = (value: any) => string
type OutputEscapeOption = 'escape' | 'json' | OutputEscape

export interface LiquidOptions {
/** A directory or an array of directories from where to resolve layout and include templates, and the filename passed to `.renderFile()`. If it's an array, the files are looked up in the order they occur in the array. Defaults to `["."]` */
Expand Down Expand Up @@ -63,6 +73,8 @@ export interface LiquidOptions {
globals?: object;
/** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */
keepOutputType?: boolean;
/** Default escape filter applied to output values, when set, you'll have to add `| raw` for values don't need to be escaped. Defaults to `undefined`. */
outputEscape?: OutputEscapeOption;
/** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
operators?: Operators;
/** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */
Expand Down Expand Up @@ -93,6 +105,7 @@ interface NormalizedOptions extends LiquidOptions {
partials?: string[];
layouts?: string[];
cache?: Cache<Thenable<Template[]>>;
outputEscape?: OutputEscape;
operatorsTrie?: Trie;
}

Expand Down Expand Up @@ -181,12 +194,24 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
options.root = normalizeDirectoryList(options.root)
options.partials = normalizeDirectoryList(options.partials)
options.layouts = normalizeDirectoryList(options.layouts)
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
return options as NormalizedFullOptions
}

function getOutputEscapeFunction (nameOrFunction: OutputEscapeOption) {
if (isString(nameOrFunction)) {
const filterImpl = filters.get(nameOrFunction)
assert(isFunction(filterImpl), `filter "${nameOrFunction}" not found`)
return filterImpl
} else {
assert(isFunction(nameOrFunction), '`outputEscape` need to be of type string or function')
return nameOrFunction
}
}

export function normalizeDirectoryList (value: any): string[] {
let list: string[] = []
if (_.isArray(value)) list = value
if (_.isString(value)) list = [value]
if (isArray(value)) list = value
if (isString(value)) list = [value]
return list
}
8 changes: 8 additions & 0 deletions src/template/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ import { Context } from '../context/context'
import { Emitter } from '../emitters/emitter'
import { OutputToken } from '../tokens/output-token'
import { Liquid } from '../liquid'
import { Filter } from './filter/filter'

export class Output extends TemplateImpl<OutputToken> implements Template {
private value: Value
public constructor (token: OutputToken, liquid: Liquid) {
super(token)
this.value = new Value(token.content, liquid)
const filters = this.value.filters
const outputEscape = liquid.options.outputEscape
if (filters.length && filters[filters.length - 1].name === 'raw') {
filters.pop()
} else if (outputEscape) {
filters.push(new Filter(toString.call(outputEscape), outputEscape, [], liquid))
}
}
public * render (ctx: Context, emitter: Emitter): IterableIterator<unknown> {
const val = yield this.value.value(ctx, false)
Expand Down
4 changes: 2 additions & 2 deletions src/util/underscore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Drop } from '../drop/drop'

const toStr = Object.prototype.toString
export const toString = Object.prototype.toString
const toLowerCase = String.prototype.toLowerCase

export const hasOwnProperty = Object.hasOwnProperty
Expand Down Expand Up @@ -57,7 +57,7 @@ export function isNil (value: any): boolean {

export function isArray (value: any): value is any[] {
// be compatible with IE 8
return toStr.call(value) === '[object Array]'
return toString.call(value) === '[object Array]'
}

/*
Expand Down
42 changes: 42 additions & 0 deletions test/integration/liquid/outputEscape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect } from 'chai'
import { Liquid } from '../../../src/liquid'

describe('LiquidOptions#*outputEscape*', function () {
it('when outputEscape is not set', async function () {
const engine = new Liquid()
const html = await engine.parseAndRender('{{"<"}}')
expect(html).to.equal('<')
})

it('should escape when outputEscape="escape"', async function () {
const engine = new Liquid({
outputEscape: 'escape'
})
const html = await engine.parseAndRender('{{"<"}}')
expect(html).to.equal('&lt;')
})

it('should json stringify when outputEscape="json"', async function () {
const engine = new Liquid({
outputEscape: 'json'
})
const html = await engine.parseAndRender('{{"<"}}')
expect(html).to.equal('"<"')
})

it('should support outputEscape=Function', async function () {
const engine = new Liquid({
outputEscape: (v: any) => `{${v}}`
})
const html = await engine.parseAndRender('{{"<"}}')
expect(html).to.equal('{<}')
})

it('should skip escape for output with filter "| raw"', async function () {
const engine = new Liquid({
outputEscape: 'escape'
})
const html = await engine.parseAndRender('{{"<" | raw}}')
expect(html).to.equal('<')
})
})

0 comments on commit f88490c

Please sign in to comment.