feat: automatic output escaping, closes #500

This commit is contained in:
Harttle
2022-04-22 02:19:36 +08:00
parent e69a51025e
commit f88490cd3c
9 changed files with 188 additions and 7 deletions
+1
View File
@@ -51,6 +51,7 @@ filters:
newline_to_br: newline_to_br.html newline_to_br: newline_to_br.html
plus: plus.html plus: plus.html
prepend: prepend.html prepend: prepend.html
raw: raw.html
remove: remove.html remove: remove.html
remove_first: remove_first.html remove_first: remove_first.html
replace: replace.html replace: replace.html
+1 -1
View File
@@ -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 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, first, last, join, reverse, concat, compact, size
Date | date Date | date
Misc | default, json Misc | default, json, raw
[shopify/liquid]: https://github.com/Shopify/liquid [shopify/liquid]: https://github.com/Shopify/liquid
+51
View File
@@ -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
View File
@@ -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
<
```
+3 -1
View File
@@ -1,5 +1,5 @@
import { isFalsy } from '../../render/boolean' 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' 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 { export function Default<T1 extends boolean, T2> (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 {
@@ -12,3 +12,5 @@ export function Default<T1 extends boolean, T2> (this: FilterImpl, value: T1, de
export function json (value: any) { export function json (value: any) {
return JSON.stringify(value) return JSON.stringify(value)
} }
export const raw = identify
+28 -3
View File
@@ -1,4 +1,4 @@
import * as _ from './util/underscore' import { snakeCase, forOwn, isArray, isString, isFunction } from './util/underscore'
import { Template } from './template/template' import { Template } from './template/template'
import { Cache } from './cache/cache' import { Cache } from './cache/cache'
import { LRU } from './cache/lru' import { LRU } from './cache/lru'
@@ -7,6 +7,16 @@ import * as fs from './fs/node'
import { defaultOperators, Operators } from './render/operator' import { defaultOperators, Operators } from './render/operator'
import { createTrie, Trie } from './util/operator-trie' import { createTrie, Trie } from './util/operator-trie'
import { Thenable } from './util/async' 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 { 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 `["."]` */ /** 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 `["."]` */
@@ -63,6 +73,8 @@ export interface LiquidOptions {
globals?: object; globals?: object;
/** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */ /** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */
keepOutputType?: boolean; 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. */ /** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
operators?: Operators; operators?: Operators;
/** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */ /** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */
@@ -93,6 +105,7 @@ interface NormalizedOptions extends LiquidOptions {
partials?: string[]; partials?: string[];
layouts?: string[]; layouts?: string[];
cache?: Cache<Thenable<Template[]>>; cache?: Cache<Thenable<Template[]>>;
outputEscape?: OutputEscape;
operatorsTrie?: Trie; operatorsTrie?: Trie;
} }
@@ -181,12 +194,24 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
options.root = normalizeDirectoryList(options.root) options.root = normalizeDirectoryList(options.root)
options.partials = normalizeDirectoryList(options.partials) options.partials = normalizeDirectoryList(options.partials)
options.layouts = normalizeDirectoryList(options.layouts) options.layouts = normalizeDirectoryList(options.layouts)
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
return options as NormalizedFullOptions 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[] { export function normalizeDirectoryList (value: any): string[] {
let list: string[] = [] let list: string[] = []
if (_.isArray(value)) list = value if (isArray(value)) list = value
if (_.isString(value)) list = [value] if (isString(value)) list = [value]
return list return list
} }
+8
View File
@@ -5,12 +5,20 @@ import { Context } from '../context/context'
import { Emitter } from '../emitters/emitter' import { Emitter } from '../emitters/emitter'
import { OutputToken } from '../tokens/output-token' import { OutputToken } from '../tokens/output-token'
import { Liquid } from '../liquid' import { Liquid } from '../liquid'
import { Filter } from './filter/filter'
export class Output extends TemplateImpl<OutputToken> implements Template { export class Output extends TemplateImpl<OutputToken> implements Template {
private value: Value private value: Value
public constructor (token: OutputToken, liquid: Liquid) { public constructor (token: OutputToken, liquid: Liquid) {
super(token) super(token)
this.value = new Value(token.content, liquid) 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> { public * render (ctx: Context, emitter: Emitter): IterableIterator<unknown> {
const val = yield this.value.value(ctx, false) const val = yield this.value.value(ctx, false)
+2 -2
View File
@@ -1,6 +1,6 @@
import { Drop } from '../drop/drop' import { Drop } from '../drop/drop'
const toStr = Object.prototype.toString export const toString = Object.prototype.toString
const toLowerCase = String.prototype.toLowerCase const toLowerCase = String.prototype.toLowerCase
export const hasOwnProperty = Object.hasOwnProperty export const hasOwnProperty = Object.hasOwnProperty
@@ -57,7 +57,7 @@ export function isNil (value: any): boolean {
export function isArray (value: any): value is any[] { export function isArray (value: any): value is any[] {
// be compatible with IE 8 // be compatible with IE 8
return toStr.call(value) === '[object Array]' return toString.call(value) === '[object Array]'
} }
/* /*
+42
View File
@@ -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('<')
})
})