diff --git a/src/util/collection.ts b/src/util/collection.ts index 48cd75b23..677095e21 100644 --- a/src/util/collection.ts +++ b/src/util/collection.ts @@ -1,8 +1,9 @@ -import { isNil, isString, isObject, isArray } from './underscore' +import { isNil, isString, isObject, isArray, isIterable } from './underscore' export function toEnumerable (val: any) { if (isArray(val)) return val if (isString(val) && val.length > 0) return [val] + if (isIterable(val)) return Array.from(val) if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) return [] } diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 11bed1a58..15143f155 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -60,6 +60,10 @@ export function isArray (value: any): value is any[] { return toString.call(value) === '[object Array]' } +export function isIterable (value: any): value is Iterable { + return isObject(value) && Symbol.iterator in value +} + /* * Iterates over own enumerable string keyed properties of an object and invokes iteratee for each property. * The iteratee is invoked with three arguments: (value, key, object). diff --git a/test/integration/builtin/tags/for.ts b/test/integration/builtin/tags/for.ts index 2fcce2be1..dc40c40a3 100644 --- a/test/integration/builtin/tags/for.ts +++ b/test/integration/builtin/tags/for.ts @@ -1,4 +1,4 @@ -import { Liquid } from '../../../../src/liquid' +import { Drop, Liquid } from '../../../../src/liquid' import { expect, use } from 'chai' import * as chaiAsPromised from 'chai-as-promised' import { Scope } from '../../../../src/context/scope' @@ -304,4 +304,66 @@ describe('tags/for', function () { return expect(html).to.equal('b') }) }) + + describe('iterables', function () { + class MockIterable { + * [Symbol.iterator] () { + yield 'a' + yield 'b' + yield 'c' + } + } + + class MockEmptyIterable { + * [Symbol.iterator] () {} + } + + class MockIterableDrop extends Drop { + * [Symbol.iterator] () { + yield 'a' + yield 'b' + yield 'c' + } + + public valueOf (): string { + return 'MockIterableDrop' + } + } + + it('should loop over iterable objects', function () { + const src = '{% for i in someIterable %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someIterable: new MockIterable() }) + return expect(html).to.equal('abc') + }) + it('should loop over iterable drops', function () { + const src = '{{ someDrop }}: {% for i in someDrop %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someDrop: new MockIterableDrop() }) + return expect(html).to.equal('MockIterableDrop: abc') + }) + it('should loop over iterable objects with a limit', function () { + const src = '{% for i in someIterable limit:2 %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someIterable: new MockIterable() }) + return expect(html).to.equal('ab') + }) + it('should loop over iterable objects with an offset', function () { + const src = '{% for i in someIterable offset:1 %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someIterable: new MockIterable() }) + return expect(html).to.equal('bc') + }) + it('should loop over iterable objects in reverse', function () { + const src = '{% for i in someIterable reversed %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someIterable: new MockIterable() }) + return expect(html).to.equal('cba') + }) + it('should go to else for an empty iterable', function () { + const src = '{% for i in emptyIterable reversed %}{{i}}{%else%}EMPTY{%endfor%}' + const html = liquid.parseAndRenderSync(src, { emptyIterable: new MockEmptyIterable() }) + return expect(html).to.equal('EMPTY') + }) + it('should support iterable names', function () { + const src = '{% for i in someDrop %}{{forloop.name}} {%else%}EMPTY{%endfor%}' + const html = liquid.parseAndRenderSync(src, { someDrop: new MockIterableDrop() }) + return expect(html).to.equal('i-someDrop i-someDrop i-someDrop ') + }) + }) }) diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts index aa769756d..3125b2688 100644 --- a/test/integration/builtin/tags/render.ts +++ b/test/integration/builtin/tags/render.ts @@ -155,6 +155,20 @@ describe('tags/render', function () { const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] }) expect(html).to.equal('1: red\n2: green\n') }) + it('should support for as', async function () { + class MockIterable { + * [Symbol.iterator] () { + yield 'red' + yield 'green' + } + } + mock({ + '/index.html': '{% render "item" for colors as color %}', + '/item.html': '{{forloop.index}}: {{color}}\n' + }) + const html = await liquid.renderFile('index.html', { colors: new MockIterable() }) + expect(html).to.equal('1: red\n2: green\n') + }) it('should support for as', async function () { mock({ '/index.html': '{% render "item" for "green" as color %}', diff --git a/test/integration/builtin/tags/tablerow.ts b/test/integration/builtin/tags/tablerow.ts index 71a6d1bc5..78ae4697a 100644 --- a/test/integration/builtin/tags/tablerow.ts +++ b/test/integration/builtin/tags/tablerow.ts @@ -24,6 +24,23 @@ describe('tags/tablerow', function () { return expect(html).to.equal(dst) }) + it('should support iterables', async function () { + class MockIterable { + * [Symbol.iterator] () { + yield 1 + yield 2 + yield 3 + } + } + const src = '{% tablerow i in someIterable %}{{ i }}{% endtablerow %}' + const ctx = { + someIterable: new MockIterable() + } + const dst = '123' + const html = await liquid.parseAndRender(src, ctx) + return expect(html).to.equal(dst) + }) + it('should support cols', async function () { const src = '{% tablerow i in alpha cols:2 %}{{ i }}{% endtablerow %}' const ctx = {