diff --git a/package.json b/package.json index 12d17bd410..9645d9097d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "perf:diff": "bin/perf-diff.sh", "perf:engines": "cd benchmark && npm run engines", "build": "npm run build:dist && npm run build:docs", - "build:dist": "rm -rf dist && rollup -c rollup.config.ts && ls -lh dist", + "build:dist": "rollup -c rollup.config.ts && ls -lh dist", "build:docs": "bin/build-docs.sh" }, "bin": { diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts index a51808f2cb..b3954f4922 100644 --- a/src/builtin/tags/include.ts +++ b/src/builtin/tags/include.ts @@ -32,7 +32,7 @@ export default { ctx.setRegister('blockMode', BlockMode.OUTPUT) const scope = yield hash.render(ctx) if (withVar) scope[filepath] = evalToken(withVar, ctx) - const templates = yield liquid.parseFileImpl(filepath, ctx.sync) + const templates = yield liquid._parsePartialFile(filepath, ctx.sync) ctx.push(scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts index 8e32a94bad..b36efbddfc 100644 --- a/src/builtin/tags/layout.ts +++ b/src/builtin/tags/layout.ts @@ -10,7 +10,7 @@ export default { const tokenizer = new Tokenizer(token.args, this.liquid.options.operatorsTrie) this['file'] = this.parseFilePath(tokenizer, this.liquid) this.hash = new Hash(tokenizer.remaining()) - this.tpls = this.liquid.parser.parse(remainTokens) + this.tpls = this.liquid.parser.parseTokens(remainTokens) }, render: function * (ctx: Context, emitter: Emitter) { const { liquid, hash, file } = this @@ -22,7 +22,7 @@ export default { } const filepath = yield this.renderFilePath(this['file'], ctx, liquid) assert(filepath, () => `illegal filename "${filepath}"`) - const templates = yield liquid.parseFileImpl(filepath, ctx.sync) + const templates = yield liquid._parseLayoutFile(filepath, ctx.sync) // render remaining contents and store rendered results ctx.setRegister('blockMode', BlockMode.STORE) diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts index 3b90a04590..38eb9578b5 100644 --- a/src/builtin/tags/render.ts +++ b/src/builtin/tags/render.ts @@ -65,12 +65,12 @@ export default { scope['forloop'] = new ForloopDrop(collection.length) for (const item of collection) { scope[alias] = item - const templates = yield liquid.parseFileImpl(filepath, childCtx.sync) + const templates = yield liquid._parsePartialFile(filepath, childCtx.sync) yield liquid.renderer.renderTemplates(templates, childCtx, emitter) scope.forloop.next() } } else { - const templates = yield liquid.parseFileImpl(filepath, childCtx.sync) + const templates = yield liquid._parsePartialFile(filepath, childCtx.sync) yield liquid.renderer.renderTemplates(templates, childCtx, emitter) } } diff --git a/src/fs/loader.ts b/src/fs/loader.ts new file mode 100644 index 0000000000..9fc7b0005e --- /dev/null +++ b/src/fs/loader.ts @@ -0,0 +1,48 @@ +import { FS } from './fs' + +interface LoaderOptions { + fs: FS; + extname: string; + root: string[]; + partials: string[]; + layouts: string[]; +} +export enum LookupType { + Partials = 'partials', + Layouts = 'layouts', + Root = 'root' +} +export class Loader { + private options: LoaderOptions + + constructor (options: LoaderOptions) { + this.options = options + } + + public * lookup (file: string, type: LookupType, sync?: boolean) { + const { fs, root } = this.options + for (const filepath of this.candidates(file, type)) { + if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath + } + throw this.lookupError(file, root) + } + + private * candidates (file: string, type: LookupType) { + const { fs, extname } = this.options + const dirs = this.options[type] + for (const dir of dirs) { + yield fs.resolve(dir, file, extname) + } + if (fs.fallback !== undefined) { + const filepath = fs.fallback(file) + if (filepath !== undefined) yield filepath + } + } + + private lookupError (file: string, roots: string[]) { + const err = new Error('ENOENT') as any + err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"` + err.code = 'ENOENT' + return err + } +} diff --git a/src/liquid-options.ts b/src/liquid-options.ts index ba3978c297..7a6d70b944 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -11,6 +11,10 @@ import { timezoneOffset } from './util/strftime' 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 `["."]` */ root?: string | string[]; + /** A directory or an array of directories from where to resolve included templates. If it's an array, the files are looked up in the order they occur in the array. Defaults to `root` */ + partials?: string | string[]; + /** A directory or an array of directories from where to resolve layout templates. If it's an array, the files are looked up in the order they occur in the array. Defaults to `root` */ + layouts?: string | string[]; /** Add a extname (if filepath doesn't include one) before template file lookup. Eg: setting to `".html"` will allow including file by basename. Defaults to `""`. */ extname?: string; /** Whether or not to cache resolved templates. Defaults to `false`. */ @@ -61,12 +65,16 @@ export interface LiquidOptions { interface NormalizedOptions extends LiquidOptions { root?: string[]; + partials?: string[]; + layouts?: string[]; cache?: Cache; operatorsTrie?: Trie; } export interface NormalizedFullOptions extends NormalizedOptions { root: string[]; + partials: string[]; + layouts: string[]; extname: string; cache: undefined | Cache; jsTruthy: boolean; @@ -93,6 +101,8 @@ export interface NormalizedFullOptions extends NormalizedOptions { export const defaultOptions: NormalizedFullOptions = { root: ['.'], + layouts: ['.'], + partials: ['.'], cache: undefined, extname: '', fs: fs, @@ -123,6 +133,12 @@ export function normalize (options?: LiquidOptions): NormalizedOptions { if (options.hasOwnProperty('root')) { options.root = normalizeStringArray(options.root) } + if (options.hasOwnProperty('partials')) { + options.partials = normalizeStringArray(options.partials) + } + if (options.hasOwnProperty('layouts')) { + options.layouts = normalizeStringArray(options.layouts) + } if (options.hasOwnProperty('cache')) { let cache: Cache | undefined if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.cache) : undefined @@ -137,7 +153,14 @@ export function normalize (options?: LiquidOptions): NormalizedOptions { } export function applyDefault (options: NormalizedOptions): NormalizedFullOptions { - return { ...defaultOptions, ...options } + const fullOptions = { ...defaultOptions, ...options } + if (fullOptions.partials === defaultOptions.partials) { + fullOptions.partials = fullOptions.root + } + if (fullOptions.layouts === defaultOptions.layouts) { + fullOptions.layouts = fullOptions.root + } + return fullOptions } export function normalizeStringArray (value: any): string[] { diff --git a/src/liquid.ts b/src/liquid.ts index 975d3ddf3d..be1ec692f2 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -1,7 +1,7 @@ import { Context } from './context/context' import { forOwn, snakeCase } from './util/underscore' import { Template } from './template/template' -import { Tokenizer } from './parser/tokenizer' +import { LookupType } from './fs/loader' import { Render } from './render/render' import Parser from './parser/parser' import { TagImplOptions } from './template/tag/tag-impl-options' @@ -18,12 +18,11 @@ export * from './util/error' export * from './types' export class Liquid { - public options: NormalizedFullOptions - public renderer: Render - public parser: Parser - public filters: FilterMap - public tags: TagMap - public parseFileImpl: (file: string, sync?: boolean) => Iterator + public readonly options: NormalizedFullOptions + public readonly renderer: Render + public readonly parser: Parser + public readonly filters: FilterMap + public readonly tags: TagMap public constructor (opts: LiquidOptions = {}) { this.options = applyDefault(normalize(opts)) @@ -31,15 +30,12 @@ export class Liquid { this.renderer = new Render() this.filters = new FilterMap(this.options.strictFilters, this) this.tags = new TagMap() - this.parseFileImpl = this.options.cache ? this._parseFileCached : this._parseFile forOwn(builtinTags, (conf: TagImplOptions, name: string) => this.registerTag(snakeCase(name), conf)) forOwn(builtinFilters, (handler: FilterImplOptions, name: string) => this.registerFilter(snakeCase(name), handler)) } public parse (html: string, filepath?: string): Template[] { - const tokenizer = new Tokenizer(html, this.options.operatorsTrie, filepath) - const tokens = tokenizer.readTopLevelTokens(this.options) - return this.parser.parse(tokens) + return this.parser.parse(html, filepath) } public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator { @@ -68,30 +64,17 @@ export class Liquid { return toValue(this._parseAndRender(html, scope, true)) } - private * _parseFileCached (file: string, sync?: boolean) { - const cache = this.options.cache! - let tpls = yield cache.read(file) - if (tpls) return tpls - - tpls = yield this._parseFile(file, sync) - cache.write(file, tpls) - return tpls + public _parsePartialFile (file: string, sync?: boolean) { + return this.parser.parseFile(file, sync, LookupType.Partials) } - private * _parseFile (file: string, sync?: boolean) { - const { fs, root } = this.options - - for (const filepath of this.lookupFiles(file, this.options)) { - if (!(sync ? fs.existsSync(filepath) : yield fs.exists(filepath))) continue - const tpl = this.parse(sync ? fs.readFileSync(filepath) : yield fs.readFile(filepath), filepath) - return tpl - } - throw this.lookupError(file, root) + public _parseLayoutFile (file: string, sync?: boolean) { + return this.parser.parseFile(file, sync, LookupType.Layouts) } public async parseFile (file: string): Promise { - return toPromise(this.parseFileImpl(file, false)) + return toPromise(this.parser.parseFile(file, false)) } public parseFileSync (file: string): Template[] { - return toValue(this.parseFileImpl(file, true)) + return toValue(this.parser.parseFile(file, true)) } public async renderFile (file: string, ctx?: object) { const templates = await this.parseFile(file) @@ -134,22 +117,4 @@ export class Liquid { self.renderFile(filePath, ctx).then(html => callback(null, html) as any, callback as any) } } - - private * lookupFiles (file: string, options: NormalizedFullOptions) { - const { root, fs, extname } = options - for (const dir of root) { - yield fs.resolve(dir, file, extname) - } - if (fs.fallback !== undefined) { - const filepath = fs.fallback(file) - if (filepath !== undefined) yield filepath - } - } - - private lookupError (file: string, roots: string[]) { - const err = new Error('ENOENT') as any - err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"` - err.code = 'ENOENT' - return err - } } diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 754ee315fc..294e62f188 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,5 +1,5 @@ import { ParseError } from '../util/error' -import { Liquid } from '../liquid' +import { Liquid, Tokenizer } from '../liquid' import { ParseStream } from './parse-stream' import { isTagToken, isOutputToken } from '../util/type-guards' import { OutputToken } from '../tokens/output-token' @@ -8,14 +8,31 @@ import { Output } from '../template/output' import { HTML } from '../template/html' import { Template } from '../template/template' import { TopLevelToken } from '../tokens/toplevel-token' +import { Cache } from '../cache/cache' +import { Loader, LookupType } from '../fs/loader' +import { FS } from '../fs/fs' export default class Parser { + public parseFile: (file: string, sync?: boolean, type?: LookupType) => Iterator + private liquid: Liquid + private fs: FS + private cache: Cache | undefined + private loader: Loader public constructor (liquid: Liquid) { this.liquid = liquid + this.cache = this.liquid.options.cache + this.fs = this.liquid.options.fs + this.parseFile = this.cache ? this._parseFileCached : this._parseFile + this.loader = new Loader(this.liquid.options) + } + public parse (html: string, filepath?: string): Template[] { + const tokenizer = new Tokenizer(html, this.liquid.options.operatorsTrie, filepath) + const tokens = tokenizer.readTopLevelTokens(this.liquid.options) + return this.parseTokens(tokens) } - public parse (tokens: TopLevelToken[]) { + public parseTokens (tokens: TopLevelToken[]) { let token const templates: Template[] = [] while ((token = tokens.shift())) { @@ -39,4 +56,17 @@ export default class Parser { public parseStream (tokens: TopLevelToken[]) { return new ParseStream(tokens, (token, tokens) => this.parseToken(token, tokens)) } + private * _parseFileCached (file: string, sync?: boolean, type: LookupType = LookupType.Root) { + const key = type + ':' + file + let templates = yield this.cache!.read(key) + if (templates) return templates + + templates = yield this._parseFile(file, sync) + this.cache!.write(key, templates) + return templates + } + private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root) { + const filepath = yield this.loader.lookup(file, type, sync) + return this.liquid.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath) + } } diff --git a/test/integration/builtin/tags/layout.ts b/test/integration/builtin/tags/layout.ts index 0be2d30a8c..7e2e104d21 100644 --- a/test/integration/builtin/tags/layout.ts +++ b/test/integration/builtin/tags/layout.ts @@ -76,6 +76,15 @@ describe('tags/layout', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('XAYBZ') }) + it('should support `options.layouts`', async () => { + mock({ + '/layouts/parent.html': 'X{% block "a"%}{%endblock%}Y' + }) + const src = '{% layout "parent.html" %}{%block a%}A{%endblock%}' + const liquid = new Liquid({ layouts: '/layouts' }) + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('XAY') + }) it('should support block.super', async function () { mock({ '/parent.html': '{% block css %}{% endblock %}' @@ -171,7 +180,7 @@ describe('tags/layout', function () { }) describe('static partial', function () { - it('should support filename with extention', async function () { + it('should support filename with extension', async function () { mock({ '/parent.html': '{{color}}{%block%}{%endblock%}', '/main.html': '{% layout parent.html color:"black"%}{%block%}A{%endblock%}' diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts index e05ec7c0ca..4f7637639a 100644 --- a/test/integration/builtin/tags/render.ts +++ b/test/integration/builtin/tags/render.ts @@ -21,6 +21,15 @@ describe('tags/render', function () { const html = await liquid.renderFile('/current.html') expect(html).to.equal('barfoobar') }) + it('should support render', async function () { + mock({ + '/current.html': 'bar{% render "foo.html" %}bar', + '/partials/foo.html': 'foo' + }) + const liquid = new Liquid({ partials: '/partials' }) + const html = await liquid.renderFile('/current.html') + expect(html).to.equal('barfoobar') + }) it('should support template string', async function () { mock({ '/current.html': 'bar{% render "bar/{{name}}" %}bar',