Skip to content

Commit

Permalink
feat: add layouts, partials apart from root, #395
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Oct 3, 2021
1 parent 0cb520d commit b9ae479
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 58 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/builtin/tags/include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions src/builtin/tags/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/builtin/tags/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/fs/loader.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
25 changes: 24 additions & 1 deletion src/liquid-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -61,12 +65,16 @@ export interface LiquidOptions {

interface NormalizedOptions extends LiquidOptions {
root?: string[];
partials?: string[];
layouts?: string[];
cache?: Cache<Template[]>;
operatorsTrie?: Trie;
}

export interface NormalizedFullOptions extends NormalizedOptions {
root: string[];
partials: string[];
layouts: string[];
extname: string;
cache: undefined | Cache<Template[]>;
jsTruthy: boolean;
Expand All @@ -93,6 +101,8 @@ export interface NormalizedFullOptions extends NormalizedOptions {

export const defaultOptions: NormalizedFullOptions = {
root: ['.'],
layouts: ['.'],
partials: ['.'],
cache: undefined,
extname: '',
fs: fs,
Expand Down Expand Up @@ -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<Template[]> | undefined
if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.cache) : undefined
Expand All @@ -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[] {
Expand Down
61 changes: 13 additions & 48 deletions src/liquid.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -18,28 +18,24 @@ 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<Template[]>
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))
this.parser = new Parser(this)
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<any> {
Expand Down Expand Up @@ -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<Template[]> {
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)
Expand Down Expand Up @@ -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
}
}
34 changes: 32 additions & 2 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<Template[]>

private liquid: Liquid
private fs: FS
private cache: Cache<Template[]> | 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())) {
Expand All @@ -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)
}
}
11 changes: 10 additions & 1 deletion test/integration/builtin/tags/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}<link href="base.css" rel="stylesheet">{% endblock %}'
Expand Down Expand Up @@ -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%}'
Expand Down
9 changes: 9 additions & 0 deletions test/integration/builtin/tags/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit b9ae479

Please sign in to comment.