From 479c63350ac2fca6ebd217472088216fcf277c6d Mon Sep 17 00:00:00 2001 From: harttle Date: Tue, 24 Mar 2020 23:57:26 +0800 Subject: [PATCH] fix: throws on invalid arguments for prepend/append, fixes #208 --- src/builtin/filters/array.ts | 31 ++++----- src/builtin/filters/date.ts | 20 +++--- src/builtin/filters/html.ts | 17 +++-- src/builtin/filters/index.ts | 16 ++--- src/builtin/filters/math.ts | 35 +++++----- src/builtin/filters/object.ts | 12 ++-- src/builtin/filters/string.ts | 76 +++++++++++++++------- src/builtin/filters/url.ts | 6 +- src/liquid.ts | 8 +-- src/util/underscore.ts | 7 ++ test/integration/builtin/filters/html.ts | 13 ++++ test/integration/builtin/filters/string.ts | 30 +++++---- test/unit/util/underscore.ts | 9 +++ 13 files changed, 165 insertions(+), 115 deletions(-) diff --git a/src/builtin/filters/array.ts b/src/builtin/filters/array.ts index 7ccb732847..e9693795a9 100644 --- a/src/builtin/filters/array.ts +++ b/src/builtin/filters/array.ts @@ -1,42 +1,35 @@ -import { isArray, last } from '../../util/underscore' +import { isArray, last as arrayLast } from '../../util/underscore' import { isTruthy } from '../../render/boolean' import { FilterImpl } from '../../template/filter/filter-impl' -export default { - 'join': (v: any[], arg: string) => v.join(arg === undefined ? ' ' : arg), - 'last': (v: any) => isArray(v) ? last(v) : '', - 'first': (v: any) => isArray(v) ? v[0] : '', - 'map': map, - 'reverse': (v: any[]) => [...v].reverse(), - 'sort': (v: T[], arg: (lhs: T, rhs: T) => number) => v.sort(arg), - 'size': (v: string | any[]) => (v && v.length) || 0, - 'concat': concat, - 'slice': slice, - 'uniq': uniq, - 'where': where -} +export const join = (v: any[], arg: string) => v.join(arg === undefined ? ' ' : arg) +export const last = (v: any) => isArray(v) ? arrayLast(v) : '' +export const first = (v: any) => isArray(v) ? v[0] : '' +export const reverse = (v: any[]) => [...v].reverse() +export const sort = (v: T[], arg: (lhs: T, rhs: T) => number) => v.sort(arg) +export const size = (v: string | any[]) => (v && v.length) || 0 -function map (arr: {[key: string]: T1}[], arg: string): T1[] { +export function map (arr: {[key: string]: T1}[], arg: string): T1[] { return arr.map(v => v[arg]) } -function concat (v: T1[], arg: T2[] | T2): (T1 | T2)[] { +export function concat (v: T1[], arg: T2[] | T2): (T1 | T2)[] { return Array.prototype.concat.call(v, arg) } -function slice (v: T[], begin: number, length = 1): T[] { +export function slice (v: T[], begin: number, length = 1): T[] { begin = begin < 0 ? v.length + begin : begin return v.slice(begin, begin + length) } -function where (this: FilterImpl, arr: T[], property: string, expected?: any): T[] { +export function where (this: FilterImpl, arr: T[], property: string, expected?: any): T[] { return arr.filter(obj => { const value = this.context.getFromScope(obj, property.split('.')) return expected === undefined ? isTruthy(value) : value === expected }) } -function uniq (arr: T[]): T[] { +export function uniq (arr: T[]): T[] { const u = {} return (arr || []).filter(val => { if (u.hasOwnProperty(String(val))) return false diff --git a/src/builtin/filters/date.ts b/src/builtin/filters/date.ts index eb35780bb4..96b00f4d34 100644 --- a/src/builtin/filters/date.ts +++ b/src/builtin/filters/date.ts @@ -1,18 +1,16 @@ import strftime from '../../util/strftime' import { isString, isNumber } from '../../util/underscore' -export default { - 'date': (v: string | Date, arg: string) => { - let date = v - if (v === 'now' || v === 'today') { - date = new Date() - } else if (isNumber(v)) { - date = new Date(v * 1000) - } else if (isString(v)) { - date = /^\d+$/.test(v) ? new Date(+v * 1000) : new Date(v) - } - return isValidDate(date) ? strftime(date, arg) : v +export function date (v: string | Date, arg: string) { + let date = v + if (v === 'now' || v === 'today') { + date = new Date() + } else if (isNumber(v)) { + date = new Date(v * 1000) + } else if (isString(v)) { + date = /^\d+$/.test(v) ? new Date(+v * 1000) : new Date(v) } + return isValidDate(date) ? strftime(date, arg) : v } function isValidDate (date: any): date is Date { diff --git a/src/builtin/filters/html.ts b/src/builtin/filters/html.ts index ceec3d2b3b..058180a8f9 100644 --- a/src/builtin/filters/html.ts +++ b/src/builtin/filters/html.ts @@ -15,7 +15,7 @@ const unescapeMap = { ''': "'" } -function escape (str: string) { +export function escape (str: string) { return stringify(str).replace(/&|<|>|"|'/g, m => escapeMap[m]) } @@ -23,9 +23,14 @@ function unescape (str: string) { return String(str).replace(/&(amp|lt|gt|#34|#39);/g, m => unescapeMap[m]) } -export default { - 'escape': escape, - 'escape_once': (str: string) => escape(unescape(str)), - 'newline_to_br': (v: string) => v.replace(/\n/g, '
'), - 'strip_html': (v: string) => v.replace(/|||<.*?>/g, '') +export function escapeOnce (str: string) { + return escape(unescape(str)) +} + +export function newlineToBr (v: string) { + return v.replace(/\n/g, '
') +} + +export function stripHtml (v: string) { + return v.replace(/|||<.*?>/g, '') } diff --git a/src/builtin/filters/index.ts b/src/builtin/filters/index.ts index c3bdc588a4..5d52faa342 100644 --- a/src/builtin/filters/index.ts +++ b/src/builtin/filters/index.ts @@ -1,9 +1,7 @@ -import html from './html' -import str from './string' -import math from './math' -import url from './url' -import array from './array' -import date from './date' -import obj from './object' - -export default { ...html, ...str, ...math, ...url, ...date, ...obj, ...array } +export * from './html' +export * from './math' +export * from './url' +export * from './array' +export * from './date' +export * from './object' +export * from './string' diff --git a/src/builtin/filters/math.ts b/src/builtin/filters/math.ts index 687f7653cb..a1670b9868 100644 --- a/src/builtin/filters/math.ts +++ b/src/builtin/filters/math.ts @@ -1,24 +1,25 @@ import { caseInsensitiveCompare } from '../../util/underscore' -export default { - 'abs': (v: number) => Math.abs(v), - 'at_least': (v: number, n: number) => Math.max(v, n), - 'at_most': (v: number, n: number) => Math.min(v, n), - 'ceil': (v: number) => Math.ceil(v), - 'divided_by': (v: number, arg: number) => v / arg, - 'floor': (v: number) => Math.floor(v), - 'minus': (v: number, arg: number) => v - arg, - 'modulo': (v: number, arg: number) => v % arg, - 'round': (v: number, arg = 0) => { - const amp = Math.pow(10, arg) - return Math.round(v * amp) / amp - }, - 'plus': (v: number, arg: number) => Number(v) + Number(arg), - 'sort_natural': sortNatural, - 'times': (v: number, arg: number) => v * arg +export const abs = Math.abs +export const atLeast = Math.max +export const atMost = Math.min +export const ceil = Math.ceil +export const dividedBy = (v: number, arg: number) => v / arg +export const floor = Math.floor +export const minus = (v: number, arg: number) => v - arg +export const modulo = (v: number, arg: number) => v % arg +export const times = (v: number, arg: number) => v * arg + +export function round (v: number, arg = 0) { + const amp = Math.pow(10, arg) + return Math.round(v * amp) / amp +} + +export function plus (v: number, arg: number) { + return Number(v) + Number(arg) } -function sortNatural (input: any[], property?: string) { +export function sortNatural (input: any[], property?: string) { if (!input || !input.sort) return [] if (property !== undefined) { return [...input].sort( diff --git a/src/builtin/filters/object.ts b/src/builtin/filters/object.ts index 97d3531b96..33456ade48 100644 --- a/src/builtin/filters/object.ts +++ b/src/builtin/filters/object.ts @@ -1,11 +1,9 @@ import { isFalsy } from '../../render/boolean' import { toValue } from '../../util/underscore' -export default { - 'default': function (v: string | T1, arg: T2): string | T1 | T2 { - return isFalsy(toValue(v)) || v === '' ? arg : v - }, - 'json': function (v: any) { - return JSON.stringify(v) - } +export function Default (v: string | T1, arg: T2): string | T1 | T2 { + return isFalsy(toValue(v)) || v === '' ? arg : v +} +export function json (v: any) { + return JSON.stringify(v) } diff --git a/src/builtin/filters/string.ts b/src/builtin/filters/string.ts index 6d82fec7c9..054fc5555c 100644 --- a/src/builtin/filters/string.ts +++ b/src/builtin/filters/string.ts @@ -4,46 +4,74 @@ * * prefer stringify() to String() since `undefined`, `null` should eval '' */ import { stringify } from '../../util/underscore' +import { assert } from '../../util/assert' -export default { - 'append': (v: string, arg: string) => stringify(v) + stringify(arg), - 'prepend': (v: string, arg: string) => stringify(arg) + stringify(v), - 'capitalize': capitalize, - 'lstrip': (v: string) => stringify(v).replace(/^\s+/, ''), - 'downcase': (v: string) => stringify(v).toLowerCase(), - 'upcase': (str: string) => stringify(str).toUpperCase(), - 'remove': (v: string, arg: string) => stringify(v).split(arg).join(''), - 'remove_first': (v: string, l: string) => stringify(v).replace(l, ''), - 'replace': replace, - 'replace_first': replaceFirst, - 'rstrip': (str: string) => stringify(str).replace(/\s+$/, ''), - 'split': (v: string, arg: string) => stringify(v).split(arg), - 'strip': (v: string) => stringify(v).trim(), - 'strip_newlines': (v: string) => stringify(v).replace(/\n/g, ''), - 'truncate': truncate, - 'truncatewords': truncateWords -} - -function capitalize (str: string) { +export function append (v: string, arg: string) { + assert(arg !== undefined, () => 'append expect 2 arguments') + return stringify(v) + stringify(arg) +} + +export function prepend (v: string, arg: string) { + assert(arg !== undefined, () => 'prepend expect 2 arguments') + return stringify(arg) + stringify(v) +} + +export function lstrip (v: string) { + return stringify(v).replace(/^\s+/, '') +} + +export function downcase (v: string) { + return stringify(v).toLowerCase() +} + +export function upcase (str: string) { + return stringify(str).toUpperCase() +} + +export function remove (v: string, arg: string) { + return stringify(v).split(arg).join('') +} + +export function removeFirst (v: string, l: string) { + return stringify(v).replace(l, '') +} + +export function rstrip (str: string) { + return stringify(str).replace(/\s+$/, '') +} + +export function split (v: string, arg: string) { + return stringify(v).split(arg) +} + +export function strip (v: string) { + return stringify(v).trim() +} + +export function stripNewlines (v: string) { + return stringify(v).replace(/\n/g, '') +} + +export function capitalize (str: string) { str = stringify(str) return str.charAt(0).toUpperCase() + str.slice(1) } -function replace (v: string, pattern: string, replacement: string) { +export function replace (v: string, pattern: string, replacement: string) { return stringify(v).split(pattern).join(replacement) } -function replaceFirst (v: string, arg1: string, arg2: string) { +export function replaceFirst (v: string, arg1: string, arg2: string) { return stringify(v).replace(arg1, arg2) } -function truncate (v: string, l = 50, o = '...') { +export function truncate (v: string, l = 50, o = '...') { v = stringify(v) if (v.length <= l) return v return v.substr(0, l - o.length) + o } -function truncateWords (v: string, l = 15, o = '...') { +export function truncatewords (v: string, l = 15, o = '...') { const arr = v.split(/\s+/) let ret = arr.slice(0, l).join(' ') if (arr.length >= l) ret += o diff --git a/src/builtin/filters/url.ts b/src/builtin/filters/url.ts index d3f5024a16..f14e73a9b5 100644 --- a/src/builtin/filters/url.ts +++ b/src/builtin/filters/url.ts @@ -1,4 +1,2 @@ -export default { - 'url_decode': (x: string) => x.split('+').map(decodeURIComponent).join(' '), - 'url_encode': (x: string) => x.split(' ').map(encodeURIComponent).join('+') -} +export const urlDecode = (x: string) => x.split('+').map(decodeURIComponent).join(' ') +export const urlEncode = (x: string) => x.split(' ').map(encodeURIComponent).join('+') diff --git a/src/liquid.ts b/src/liquid.ts index d68d4c57c1..5277a36020 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -1,6 +1,6 @@ import { Context } from './context/context' import * as fs from './fs/node' -import * as _ from './util/underscore' +import { forOwn, snakeCase } from './util/underscore' import { Template } from './template/template' import { Tokenizer } from './parser/tokenizer' import { Render } from './render/render' @@ -8,7 +8,7 @@ import Parser from './parser/parser' import { TagImplOptions } from './template/tag/tag-impl-options' import { Value } from './template/value' import builtinTags from './builtin/tags' -import builtinFilters from './builtin/filters' +import * as builtinFilters from './builtin/filters' import { TagMap } from './template/tag/tag-map' import { FilterMap } from './template/filter/filter-map' import { LiquidOptions, normalizeStringArray, NormalizedFullOptions, applyDefault, normalize } from './liquid-options' @@ -34,8 +34,8 @@ export class Liquid { this.filters = new FilterMap(this.options.strictFilters) this.tags = new TagMap() - _.forOwn(builtinTags, (conf, name) => this.registerTag(name, conf)) - _.forOwn(builtinFilters, (handler, name) => this.registerFilter(name, handler)) + forOwn(builtinTags, (conf, name) => this.registerTag(snakeCase(name), conf)) + forOwn(builtinFilters, (handler, name) => this.registerFilter(snakeCase(name), handler)) } public parse (html: string, filepath?: string): Template[] { const tokenizer = new Tokenizer(html, filepath) diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 96340e4b2a..f5b1adc8f9 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -120,6 +120,13 @@ export function identify (val: T): T { return val } +export function snakeCase (str: string) { + return str.replace( + /(\w?)([A-Z])/g, + (_, a, b) => (a ? a + '_' : '') + b.toLowerCase() + ) +} + export function changeCase (str: string): string { const hasLowerCase = [...str].some(ch => ch >= 'a' && ch <= 'z') return hasLowerCase ? str.toUpperCase() : str.toLowerCase() diff --git a/test/integration/builtin/filters/html.ts b/test/integration/builtin/filters/html.ts index dd5088c975..60965fd345 100644 --- a/test/integration/builtin/filters/html.ts +++ b/test/integration/builtin/filters/html.ts @@ -22,6 +22,19 @@ describe('filters/html', function () { it('should not escape twice', () => test('{{ "1 < 2 & 3" | escape_once }}', '1 < 2 & 3')) }) + describe('newline_to_br', function () { + it('should support string_with_newlines', function () { + const src = '{% capture string_with_newlines %}\n' + + 'Hello\n' + + 'there\n' + + '{% endcapture %}' + + '{{ string_with_newlines | newline_to_br }}' + const dst = '
' + + 'Hello
' + + 'there
' + return test(src, dst) + }) + }) describe('strip_html', function () { it('should strip all tags', function () { return test('{{ "Have you read Ulysses?" | strip_html }}', diff --git a/test/integration/builtin/filters/string.ts b/test/integration/builtin/filters/string.ts index 10249cb457..1c1acc7ce7 100644 --- a/test/integration/builtin/filters/string.ts +++ b/test/integration/builtin/filters/string.ts @@ -1,6 +1,9 @@ import { test } from '../../../stub/render' import { Liquid } from '../../../../src/liquid' -import { expect } from 'chai' +import { expect, use } from 'chai' +import * as chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) describe('filters/string', function () { let liquid: Liquid @@ -11,14 +14,24 @@ describe('filters/string', function () { it('should return "-3abc" for -3, "abc"', () => test('{{ -3 | append: "abc" }}', '-3abc')) it('should return "abar" for "a", foo', () => test('{{ "a" | append: foo }}', 'abar')) - it('should return "abc" for "abc", undefined', () => test('{{ "abc" | append: undefinedVar }}', 'abc')) + it('should throw if second argument undefined', () => { + return expect(test('{{ "abc" | append: undefinedVar }}', 'abc')).to.be.rejectedWith(/2 arguments/) + }) + it('should throw if second argument not set', () => { + return expect(test('{{ "abc" | append }}', 'abc')).to.be.rejectedWith(/2 arguments/) + }) it('should return "abcfalse" for "abc", false', () => test('{{ "abc" | append: false }}', 'abcfalse')) }) describe('prepend', function () { it('should return "-3abc" for -3, "abc"', () => test('{{ -3 | prepend: "abc" }}', 'abc-3')) it('should return "abar" for "a", foo', () => test('{{ "a" | prepend: foo }}', 'bara')) - it('should return "abc" for "abc", undefined', () => test('{{ "abc" | prepend: undefinedVar }}', 'abc')) + it('should throw if second argument undefined', () => { + return expect(test('{{ "abc" | prepend: undefinedVar }}', 'abc')).to.be.rejectedWith(/2 arguments/) + }) + it('should throw if second argument not set', () => { + return expect(test('{{ "abc" | prepend }}', 'abc')).to.be.rejectedWith(/2 arguments/) + }) it('should return "falseabc" for "abc", false', () => test('{{ "abc" | prepend: false }}', 'falseabc')) }) describe('capitalize', function () { @@ -105,17 +118,6 @@ describe('filters/string', function () { const src = '{{ " So much room for activities! " | lstrip }}' return test(src, 'So much room for activities! ') }) - it('should support string_with_newlines', function () { - const src = '{% capture string_with_newlines %}\n' + - 'Hello\n' + - 'there\n' + - '{% endcapture %}' + - '{{ string_with_newlines | newline_to_br }}' - const dst = '
' + - 'Hello
' + - 'there
' - return test(src, dst) - }) it('should support prepend', function () { return test('{% assign url = "liquidmarkup.com" %}' + '{{ "/index.html" | prepend: url }}', diff --git a/test/unit/util/underscore.ts b/test/unit/util/underscore.ts index 844960d966..623024e8de 100644 --- a/test/unit/util/underscore.ts +++ b/test/unit/util/underscore.ts @@ -7,6 +7,15 @@ const expect = chai.expect chai.use(sinonChai) describe('util/underscore', function () { + describe('.camel2snake()', function () { + it('should convert camelCase to snakeCase', function () { + expect(_.snakeCase('fooBarCoo')).to.equal('foo_bar_coo') + }) + it('should convert empty string to empty string', function () { + expect(_.snakeCase('')).to.equal('') + }) + }) + describe('.isString()', function () { it('should return true for literal string', function () { expect(_.isString('foo')).to.be.true