Skip to content

Commit 9012133

Browse files
Harttleharttle
Harttle
authored andcommitted
feat: stream rendering, closed #361 fixes #360
1 parent abaf4af commit 9012133

18 files changed

+147
-53
lines changed

.github/workflows/release.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
name: Release
2-
on:
3-
push:
4-
branches:
5-
- master
2+
on: workflow_dispatch
63
jobs:
74
release:
85
name: Release

src/builtin/tags/break.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import { Emitter, Context } from '../../types'
22

33
export default {
44
render: function (ctx: Context, emitter: Emitter) {
5-
emitter.break = true
5+
emitter['break'] = true
66
}
77
}

src/builtin/tags/continue.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import { Emitter, Context } from '../../types'
22

33
export default {
44
render: function (ctx: Context, emitter: Emitter) {
5-
emitter.continue = true
5+
emitter['continue'] = true
66
}
77
}

src/builtin/tags/for.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ export default {
5555
for (const item of collection) {
5656
scope[this.variable] = item
5757
yield r.renderTemplates(this.templates, ctx, emitter)
58-
if (emitter.break) {
59-
emitter.break = false
58+
if (emitter['break']) {
59+
emitter['break'] = false
6060
break
6161
}
62-
emitter.continue = false
62+
emitter['continue'] = false
6363
scope.forloop.next()
6464
}
6565
ctx.pop()

src/emitters/emitter.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface Emitter {
2+
write (html: any): void;
3+
end (): void;
4+
}

src/emitters/keeping-type-emitter.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { stringify, toValue } from '../util/underscore'
2+
3+
export class KeepingTypeEmitter {
4+
public html: any = '';
5+
6+
public write (html: any) {
7+
html = toValue(html)
8+
// This will only preserve the type if the value is isolated.
9+
// I.E:
10+
// {{ my-port }} -> 42
11+
// {{ my-host }}:{{ my-port }} -> 'host:42'
12+
if (typeof html !== 'string' && this.html === '') {
13+
this.html = html
14+
} else {
15+
this.html = stringify(this.html) + stringify(html)
16+
}
17+
}
18+
19+
public end () {
20+
return this.html
21+
}
22+
}

src/emitters/simple-emitter.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { stringify } from '../util/underscore'
2+
import { Emitter } from './emitter'
3+
4+
export class SimpleEmitter implements Emitter {
5+
public html: any = '';
6+
7+
public write (html: any) {
8+
this.html += stringify(html)
9+
}
10+
11+
public end () {
12+
return this.html
13+
}
14+
}

src/emitters/streamed-emitter.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { stringify } from '../util/underscore'
2+
3+
export class StreamedEmitter {
4+
public html: any = '';
5+
public stream = new (require('stream').PassThrough)()
6+
public write (html: any) {
7+
this.stream.write(stringify(html))
8+
}
9+
public end () {
10+
this.stream.end()
11+
}
12+
}

src/liquid-options.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface LiquidOptions {
5151
fs?: FS;
5252
/** the global environment passed down to all partial templates, i.e. templates included by `include`, `layout` and `render` tags. */
5353
globals?: object;
54-
/** Whether or not to keep value type when writing the Output. Defaults to `false`. */
54+
/** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */
5555
keepOutputType?: boolean;
5656
/** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
5757
operators?: Operators;

src/liquid.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { FilterMap } from './template/filter/filter-map'
1313
import { LiquidOptions, normalizeStringArray, NormalizedFullOptions, applyDefault, normalize } from './liquid-options'
1414
import { FilterImplOptions } from './template/filter/filter-impl-options'
1515
import { toPromise, toValue } from './util/async'
16-
import { Emitter } from './render/emitter'
1716

1817
export * from './util/error'
1918
export * from './types'
@@ -24,7 +23,7 @@ export class Liquid {
2423
public parser: Parser
2524
public filters: FilterMap
2625
public tags: TagMap
27-
private parseFileImpl: (file: string, sync?: boolean) => Iterator<Template[]>
26+
public parseFileImpl: (file: string, sync?: boolean) => Iterator<Template[]>
2827

2928
public constructor (opts: LiquidOptions = {}) {
3029
this.options = applyDefault(normalize(opts))
@@ -45,15 +44,18 @@ export class Liquid {
4544

4645
public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator<any> {
4746
const ctx = new Context(scope, this.options, sync)
48-
const emitter = new Emitter(this.options.keepOutputType)
49-
return this.renderer.renderTemplates(tpl, ctx, emitter)
47+
return this.renderer.renderTemplates(tpl, ctx)
5048
}
5149
public async render (tpl: Template[], scope?: object): Promise<any> {
5250
return toPromise(this._render(tpl, scope, false))
5351
}
5452
public renderSync (tpl: Template[], scope?: object): any {
5553
return toValue(this._render(tpl, scope, true))
5654
}
55+
public renderToNodeStream (tpl: Template[], scope?: object): NodeJS.ReadableStream {
56+
const ctx = new Context(scope, this.options)
57+
return this.renderer.renderTemplatesToNodeStream(tpl, ctx)
58+
}
5759

5860
public _parseAndRender (html: string, scope?: object, sync?: boolean): IterableIterator<any> {
5961
const tpl = this.parse(html)

src/render/emitter.ts

+14-28
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,15 @@
1-
import { stringify, toValue } from '../util/underscore'
2-
3-
export class Emitter {
4-
public html: any = '';
5-
public break = false;
6-
public continue = false;
7-
private keepOutputType? = false;
8-
9-
constructor (keepOutputType: boolean|undefined) {
10-
this.keepOutputType = keepOutputType
11-
}
12-
13-
public write (html: any) {
14-
if (this.keepOutputType === true) {
15-
html = toValue(html)
16-
} else {
17-
html = stringify(html)
18-
}
19-
// This will only preserve the type if the value is isolated.
20-
// I.E:
21-
// {{ my-port }} -> 42
22-
// {{ my-host }}:{{ my-port }} -> 'host:42'
23-
if (this.keepOutputType === true && typeof html !== 'string' && this.html === '') {
24-
this.html = html
25-
} else {
26-
this.html = stringify(this.html) + stringify(html)
27-
}
28-
}
1+
export interface Emitter {
2+
/**
3+
* Write a html value into emitter
4+
* @param html string, Drop or other primitive value
5+
*/
6+
write (html: any): void;
7+
/**
8+
* Notify the emitter render has ended
9+
*/
10+
end (): void;
11+
/**
12+
* Collect rendered string value immediately
13+
*/
14+
collect (): string;
2915
}

src/render/render.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
import { RenderError } from '../util/error'
22
import { Context } from '../context/context'
33
import { Template } from '../template/template'
4-
import { Emitter } from './emitter'
4+
import { Emitter } from '../emitters/emitter'
5+
import { SimpleEmitter } from '../emitters/simple-emitter'
6+
import { StreamedEmitter } from '../emitters/streamed-emitter'
7+
import { toThenable } from '../util/async'
8+
import { KeepingTypeEmitter } from '../emitters/keeping-type-emitter'
59

610
export class Render {
11+
public renderTemplatesToNodeStream (templates: Template[], ctx: Context): NodeJS.ReadableStream {
12+
const emitter = new StreamedEmitter()
13+
toThenable(this.renderTemplates(templates, ctx, emitter))
14+
return emitter.stream
15+
}
716
public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
817
if (!emitter) {
9-
emitter = new Emitter(ctx.opts.keepOutputType)
18+
emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
1019
}
1120
for (const tpl of templates) {
1221
try {
22+
// if tpl.render supports emitter, it'll return empty `html`
1323
const html = yield tpl.render(ctx, emitter)
24+
// if not, it'll return an `html`, write to the emitter for it
1425
html && emitter.write(html)
15-
if (emitter.break || emitter.continue) break
26+
if (emitter['break'] || emitter['continue']) break
1627
} catch (e) {
1728
const err = RenderError.is(e) ? e : new RenderError(e, tpl)
1829
throw err
1930
}
2031
}
21-
return emitter.html
32+
return emitter.end()
2233
}
2334
}

src/template/html.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { TemplateImpl } from '../template/template-impl'
22
import { Template } from '../template/template'
33
import { HTMLToken } from '../tokens/html-token'
44
import { Context } from '../context/context'
5-
import { Emitter } from '../render/emitter'
5+
import { Emitter } from '../emitters/emitter'
66

77
export class HTML extends TemplateImpl<HTMLToken> implements Template {
88
private str: string

src/template/output.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Value } from './value'
22
import { TemplateImpl } from '../template/template-impl'
33
import { Template } from '../template/template'
44
import { Context } from '../context/context'
5-
import { Emitter } from '../render/emitter'
5+
import { Emitter } from '../emitters/emitter'
66
import { OutputToken } from '../tokens/output-token'
77
import { Liquid } from '../liquid'
88

src/template/tag/tag-impl-options.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TagToken } from '../../tokens/tag-token'
33
import { TopLevelToken } from '../../tokens/toplevel-token'
44
import { TagImpl } from './tag-impl'
55
import { Hash } from '../../template/tag/hash'
6-
import { Emitter } from '../../render/emitter'
6+
import { Emitter } from '../../emitters/emitter'
77

88
export interface TagImplOptions {
99
parse?: (this: TagImpl, token: TagToken, remainingTokens: TopLevelToken[]) => void;

src/template/template.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Context } from '../context/context'
22
import { Token } from '../tokens/token'
3-
import { Emitter } from '../render/emitter'
3+
import { Emitter } from '../emitters/emitter'
44

55
export interface Template {
66
token: Token;

src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { TypeGuards }
33
export { ParseError, TokenizationError, AssertionError } from './util/error'
44
export { assert } from './util/assert'
55
export { Drop } from './drop/drop'
6-
export { Emitter } from './render/emitter'
6+
export { Emitter } from './emitters/emitter'
77
export { Expression } from './render/expression'
88
export { isFalsy, isTruthy } from './render/boolean'
99
export { TagToken } from './tokens/tag-token'

test/unit/render/render.ts

+48-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { Context } from '../../../src/context/context'
33
import { HTMLToken } from '../../../src/tokens/html-token'
44
import { Render } from '../../../src/render/render'
55
import { HTML } from '../../../src/template/html'
6-
import { Emitter } from '../../../src/render/emitter'
6+
import { SimpleEmitter } from '../../../src/emitters/simple-emitter'
77
import { toThenable } from '../../../src/util/async'
8+
import { Tag } from '../../../src/template/tag/tag'
9+
import { TagToken } from '../../../src/types'
810

911
describe('render', function () {
1012
let render: Render
@@ -16,8 +18,52 @@ describe('render', function () {
1618
it('should render html', async function () {
1719
const scope = new Context()
1820
const token = { getContent: () => '<p>' } as HTMLToken
19-
const html = await toThenable(render.renderTemplates([new HTML(token)], scope, new Emitter(scope.opts.keepOutputType)))
21+
const html = await toThenable(render.renderTemplates([new HTML(token)], scope, new SimpleEmitter()))
2022
return expect(html).to.equal('<p>')
2123
})
2224
})
25+
26+
describe('.renderTemplatesToNodeStream()', function () {
27+
it('should render to html stream', function (done) {
28+
const scope = new Context()
29+
const tpls = [
30+
new HTML({ getContent: () => '<p>' } as HTMLToken),
31+
new HTML({ getContent: () => '</p>' } as HTMLToken)
32+
]
33+
const stream = render.renderTemplatesToNodeStream(tpls, scope)
34+
let result = ''
35+
stream.on('data', (data) => {
36+
result += data
37+
})
38+
stream.on('end', () => {
39+
expect(result).to.equal('<p></p>')
40+
done()
41+
})
42+
})
43+
it('should render to html stream asyncly', function (done) {
44+
const scope = new Context()
45+
const tpls = [
46+
new HTML({ getContent: () => '<p>' } as HTMLToken),
47+
new Tag({ content: 'foo', args: '', name: 'foo' } as TagToken, [], {
48+
tags: {
49+
get: () => ({
50+
render: () => new Promise(
51+
resolve => setTimeout(() => resolve('async tag'), 10)
52+
)
53+
})
54+
}
55+
} as any),
56+
new HTML({ getContent: () => '</p>' } as HTMLToken)
57+
]
58+
const stream = render.renderTemplatesToNodeStream(tpls, scope)
59+
let result = ''
60+
stream.on('data', (data) => {
61+
result += data
62+
})
63+
stream.on('end', () => {
64+
expect(result).to.equal('<p>async tag</p>')
65+
done()
66+
})
67+
})
68+
})
2369
})

0 commit comments

Comments
 (0)