diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b95bab7a5..0cf31d687 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,5 @@ name: Release -on: - push: - branches: - - master +on: workflow_dispatch jobs: release: name: Release diff --git a/src/builtin/tags/break.ts b/src/builtin/tags/break.ts index c224f3899..bfa8450c9 100644 --- a/src/builtin/tags/break.ts +++ b/src/builtin/tags/break.ts @@ -2,6 +2,6 @@ import { Emitter, Context } from '../../types' export default { render: function (ctx: Context, emitter: Emitter) { - emitter.break = true + emitter['break'] = true } } diff --git a/src/builtin/tags/continue.ts b/src/builtin/tags/continue.ts index 89450340e..1f5b28cd7 100644 --- a/src/builtin/tags/continue.ts +++ b/src/builtin/tags/continue.ts @@ -2,6 +2,6 @@ import { Emitter, Context } from '../../types' export default { render: function (ctx: Context, emitter: Emitter) { - emitter.continue = true + emitter['continue'] = true } } diff --git a/src/builtin/tags/for.ts b/src/builtin/tags/for.ts index e477b579a..3f1000d33 100644 --- a/src/builtin/tags/for.ts +++ b/src/builtin/tags/for.ts @@ -55,11 +55,11 @@ export default { for (const item of collection) { scope[this.variable] = item yield r.renderTemplates(this.templates, ctx, emitter) - if (emitter.break) { - emitter.break = false + if (emitter['break']) { + emitter['break'] = false break } - emitter.continue = false + emitter['continue'] = false scope.forloop.next() } ctx.pop() diff --git a/src/emitters/emitter.ts b/src/emitters/emitter.ts new file mode 100644 index 000000000..381141d55 --- /dev/null +++ b/src/emitters/emitter.ts @@ -0,0 +1,4 @@ +export interface Emitter { + write (html: any): void; + end (): void; +} diff --git a/src/emitters/keeping-type-emitter.ts b/src/emitters/keeping-type-emitter.ts new file mode 100644 index 000000000..ac488ffd1 --- /dev/null +++ b/src/emitters/keeping-type-emitter.ts @@ -0,0 +1,22 @@ +import { stringify, toValue } from '../util/underscore' + +export class KeepingTypeEmitter { + public html: any = ''; + + public write (html: any) { + html = toValue(html) + // This will only preserve the type if the value is isolated. + // I.E: + // {{ my-port }} -> 42 + // {{ my-host }}:{{ my-port }} -> 'host:42' + if (typeof html !== 'string' && this.html === '') { + this.html = html + } else { + this.html = stringify(this.html) + stringify(html) + } + } + + public end () { + return this.html + } +} diff --git a/src/emitters/simple-emitter.ts b/src/emitters/simple-emitter.ts new file mode 100644 index 000000000..f96265b78 --- /dev/null +++ b/src/emitters/simple-emitter.ts @@ -0,0 +1,14 @@ +import { stringify } from '../util/underscore' +import { Emitter } from './emitter' + +export class SimpleEmitter implements Emitter { + public html: any = ''; + + public write (html: any) { + this.html += stringify(html) + } + + public end () { + return this.html + } +} diff --git a/src/emitters/streamed-emitter.ts b/src/emitters/streamed-emitter.ts new file mode 100644 index 000000000..1fb9fb72e --- /dev/null +++ b/src/emitters/streamed-emitter.ts @@ -0,0 +1,12 @@ +import { stringify } from '../util/underscore' + +export class StreamedEmitter { + public html: any = ''; + public stream = new (require('stream').PassThrough)() + public write (html: any) { + this.stream.write(stringify(html)) + } + public end () { + this.stream.end() + } +} diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 5a9eee705..f65905065 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -51,7 +51,7 @@ export interface LiquidOptions { fs?: FS; /** the global environment passed down to all partial templates, i.e. templates included by `include`, `layout` and `render` tags. */ globals?: object; - /** Whether or not to keep value type when writing the Output. Defaults to `false`. */ + /** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */ keepOutputType?: boolean; /** An object of operators for conditional statements. Defaults to the regular Liquid operators. */ operators?: Operators; diff --git a/src/liquid.ts b/src/liquid.ts index af5b1e43f..975d3ddf3 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -13,7 +13,6 @@ import { FilterMap } from './template/filter/filter-map' import { LiquidOptions, normalizeStringArray, NormalizedFullOptions, applyDefault, normalize } from './liquid-options' import { FilterImplOptions } from './template/filter/filter-impl-options' import { toPromise, toValue } from './util/async' -import { Emitter } from './render/emitter' export * from './util/error' export * from './types' @@ -24,7 +23,7 @@ export class Liquid { public parser: Parser public filters: FilterMap public tags: TagMap - private parseFileImpl: (file: string, sync?: boolean) => Iterator + public parseFileImpl: (file: string, sync?: boolean) => Iterator public constructor (opts: LiquidOptions = {}) { this.options = applyDefault(normalize(opts)) @@ -45,8 +44,7 @@ export class Liquid { public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator { const ctx = new Context(scope, this.options, sync) - const emitter = new Emitter(this.options.keepOutputType) - return this.renderer.renderTemplates(tpl, ctx, emitter) + return this.renderer.renderTemplates(tpl, ctx) } public async render (tpl: Template[], scope?: object): Promise { return toPromise(this._render(tpl, scope, false)) @@ -54,6 +52,10 @@ export class Liquid { public renderSync (tpl: Template[], scope?: object): any { return toValue(this._render(tpl, scope, true)) } + public renderToNodeStream (tpl: Template[], scope?: object): NodeJS.ReadableStream { + const ctx = new Context(scope, this.options) + return this.renderer.renderTemplatesToNodeStream(tpl, ctx) + } public _parseAndRender (html: string, scope?: object, sync?: boolean): IterableIterator { const tpl = this.parse(html) diff --git a/src/render/emitter.ts b/src/render/emitter.ts index 2f0eea09b..40ddece04 100644 --- a/src/render/emitter.ts +++ b/src/render/emitter.ts @@ -1,29 +1,15 @@ -import { stringify, toValue } from '../util/underscore' - -export class Emitter { - public html: any = ''; - public break = false; - public continue = false; - private keepOutputType? = false; - - constructor (keepOutputType: boolean|undefined) { - this.keepOutputType = keepOutputType - } - - public write (html: any) { - if (this.keepOutputType === true) { - html = toValue(html) - } else { - html = stringify(html) - } - // This will only preserve the type if the value is isolated. - // I.E: - // {{ my-port }} -> 42 - // {{ my-host }}:{{ my-port }} -> 'host:42' - if (this.keepOutputType === true && typeof html !== 'string' && this.html === '') { - this.html = html - } else { - this.html = stringify(this.html) + stringify(html) - } - } +export interface Emitter { + /** + * Write a html value into emitter + * @param html string, Drop or other primitive value + */ + write (html: any): void; + /** + * Notify the emitter render has ended + */ + end (): void; + /** + * Collect rendered string value immediately + */ + collect (): string; } diff --git a/src/render/render.ts b/src/render/render.ts index 3fa32cd99..5a452cfd6 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,23 +1,34 @@ import { RenderError } from '../util/error' import { Context } from '../context/context' import { Template } from '../template/template' -import { Emitter } from './emitter' +import { Emitter } from '../emitters/emitter' +import { SimpleEmitter } from '../emitters/simple-emitter' +import { StreamedEmitter } from '../emitters/streamed-emitter' +import { toThenable } from '../util/async' +import { KeepingTypeEmitter } from '../emitters/keeping-type-emitter' export class Render { + public renderTemplatesToNodeStream (templates: Template[], ctx: Context): NodeJS.ReadableStream { + const emitter = new StreamedEmitter() + toThenable(this.renderTemplates(templates, ctx, emitter)) + return emitter.stream + } public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator { if (!emitter) { - emitter = new Emitter(ctx.opts.keepOutputType) + emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter() } for (const tpl of templates) { try { + // if tpl.render supports emitter, it'll return empty `html` const html = yield tpl.render(ctx, emitter) + // if not, it'll return an `html`, write to the emitter for it html && emitter.write(html) - if (emitter.break || emitter.continue) break + if (emitter['break'] || emitter['continue']) break } catch (e) { const err = RenderError.is(e) ? e : new RenderError(e, tpl) throw err } } - return emitter.html + return emitter.end() } } diff --git a/src/template/html.ts b/src/template/html.ts index faf0d2266..50cfdb8b1 100644 --- a/src/template/html.ts +++ b/src/template/html.ts @@ -2,7 +2,7 @@ import { TemplateImpl } from '../template/template-impl' import { Template } from '../template/template' import { HTMLToken } from '../tokens/html-token' import { Context } from '../context/context' -import { Emitter } from '../render/emitter' +import { Emitter } from '../emitters/emitter' export class HTML extends TemplateImpl implements Template { private str: string diff --git a/src/template/output.ts b/src/template/output.ts index 1622f862b..9b7eed85f 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -2,7 +2,7 @@ import { Value } from './value' import { TemplateImpl } from '../template/template-impl' import { Template } from '../template/template' import { Context } from '../context/context' -import { Emitter } from '../render/emitter' +import { Emitter } from '../emitters/emitter' import { OutputToken } from '../tokens/output-token' import { Liquid } from '../liquid' diff --git a/src/template/tag/tag-impl-options.ts b/src/template/tag/tag-impl-options.ts index 432db1372..7a826b5e0 100644 --- a/src/template/tag/tag-impl-options.ts +++ b/src/template/tag/tag-impl-options.ts @@ -3,7 +3,7 @@ import { TagToken } from '../../tokens/tag-token' import { TopLevelToken } from '../../tokens/toplevel-token' import { TagImpl } from './tag-impl' import { Hash } from '../../template/tag/hash' -import { Emitter } from '../../render/emitter' +import { Emitter } from '../../emitters/emitter' export interface TagImplOptions { parse?: (this: TagImpl, token: TagToken, remainingTokens: TopLevelToken[]) => void; diff --git a/src/template/template.ts b/src/template/template.ts index 1764c5877..f210ba704 100644 --- a/src/template/template.ts +++ b/src/template/template.ts @@ -1,6 +1,6 @@ import { Context } from '../context/context' import { Token } from '../tokens/token' -import { Emitter } from '../render/emitter' +import { Emitter } from '../emitters/emitter' export interface Template { token: Token; diff --git a/src/types.ts b/src/types.ts index eec43dae9..dbe2b1deb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ export { TypeGuards } export { ParseError, TokenizationError, AssertionError } from './util/error' export { assert } from './util/assert' export { Drop } from './drop/drop' -export { Emitter } from './render/emitter' +export { Emitter } from './emitters/emitter' export { Expression } from './render/expression' export { isFalsy, isTruthy } from './render/boolean' export { TagToken } from './tokens/tag-token' diff --git a/test/unit/render/render.ts b/test/unit/render/render.ts index 4dfef1eaf..ee4316a59 100644 --- a/test/unit/render/render.ts +++ b/test/unit/render/render.ts @@ -3,8 +3,10 @@ import { Context } from '../../../src/context/context' import { HTMLToken } from '../../../src/tokens/html-token' import { Render } from '../../../src/render/render' import { HTML } from '../../../src/template/html' -import { Emitter } from '../../../src/render/emitter' +import { SimpleEmitter } from '../../../src/emitters/simple-emitter' import { toThenable } from '../../../src/util/async' +import { Tag } from '../../../src/template/tag/tag' +import { TagToken } from '../../../src/types' describe('render', function () { let render: Render @@ -16,8 +18,52 @@ describe('render', function () { it('should render html', async function () { const scope = new Context() const token = { getContent: () => '

' } as HTMLToken - const html = await toThenable(render.renderTemplates([new HTML(token)], scope, new Emitter(scope.opts.keepOutputType))) + const html = await toThenable(render.renderTemplates([new HTML(token)], scope, new SimpleEmitter())) return expect(html).to.equal('

') }) }) + + describe('.renderTemplatesToNodeStream()', function () { + it('should render to html stream', function (done) { + const scope = new Context() + const tpls = [ + new HTML({ getContent: () => '

' } as HTMLToken), + new HTML({ getContent: () => '

' } as HTMLToken) + ] + const stream = render.renderTemplatesToNodeStream(tpls, scope) + let result = '' + stream.on('data', (data) => { + result += data + }) + stream.on('end', () => { + expect(result).to.equal('

') + done() + }) + }) + it('should render to html stream asyncly', function (done) { + const scope = new Context() + const tpls = [ + new HTML({ getContent: () => '

' } as HTMLToken), + new Tag({ content: 'foo', args: '', name: 'foo' } as TagToken, [], { + tags: { + get: () => ({ + render: () => new Promise( + resolve => setTimeout(() => resolve('async tag'), 10) + ) + }) + } + } as any), + new HTML({ getContent: () => '

' } as HTMLToken) + ] + const stream = render.renderTemplatesToNodeStream(tpls, scope) + let result = '' + stream.on('data', (data) => { + result += data + }) + stream.on('end', () => { + expect(result).to.equal('

async tag

') + done() + }) + }) + }) })