diff --git a/docs/source/_data/sidebar.yml b/docs/source/_data/sidebar.yml index 256e093d4a..f8eaf46c96 100644 --- a/docs/source/_data/sidebar.yml +++ b/docs/source/_data/sidebar.yml @@ -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 diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md index f9cc7dbc5d..71bd82dcaa 100644 --- a/docs/source/filters/overview.md +++ b/docs/source/filters/overview.md @@ -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 diff --git a/docs/source/filters/raw.md b/docs/source/filters/raw.md new file mode 100644 index 0000000000..6034d5b8aa --- /dev/null +++ b/docs/source/filters/raw.md @@ -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 +< +``` + +Input (`outputEscape="json"`) +```liquid +{{ "<" }} +``` + +Output +```text +"<" +``` + +Input (`outputEscape="escape"`) +```liquid +{{ "<" | raw }} +``` + +Output +```text +< +``` diff --git a/docs/source/zh-cn/filters/raw.md b/docs/source/zh-cn/filters/raw.md new file mode 100644 index 0000000000..a406a4c30a --- /dev/null +++ b/docs/source/zh-cn/filters/raw.md @@ -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 +< +``` + +输入(`outputEscape="json"`) +```liquid +{{ "<" }} +``` + +输出 +```text +"<" +``` + +输入(`outputEscape="escape"`) +```liquid +{{ "<" | raw }} +``` + +输出 +```text +< +``` + diff --git a/src/builtin/filters/misc.ts b/src/builtin/filters/misc.ts index bdbfb4b6ed..be7b1a936e 100644 --- a/src/builtin/filters/misc.ts +++ b/src/builtin/filters/misc.ts @@ -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 (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 { @@ -12,3 +12,5 @@ export function Default (this: FilterImpl, value: T1, de export function json (value: any) { return JSON.stringify(value) } + +export const raw = identify diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 6654375376..45c29874c6 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -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' @@ -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 `["."]` */ @@ -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`. */ @@ -93,6 +105,7 @@ interface NormalizedOptions extends LiquidOptions { partials?: string[]; layouts?: string[]; cache?: Cache>; + outputEscape?: OutputEscape; operatorsTrie?: Trie; } @@ -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 } diff --git a/src/template/output.ts b/src/template/output.ts index a4d4508f15..5cabdc63c1 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -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 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 { const val = yield this.value.value(ctx, false) diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 9a1c336769..11bed1a58e 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -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 @@ -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]' } /* diff --git a/test/integration/liquid/outputEscape.ts b/test/integration/liquid/outputEscape.ts new file mode 100644 index 0000000000..bf079899de --- /dev/null +++ b/test/integration/liquid/outputEscape.ts @@ -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('<') + }) + + 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('<') + }) +})