feat: add layouts, partials apart from root, #395

This commit is contained in:
harttle
2021-10-03 18:51:14 +08:00
parent 0cb520d2d1
commit b9ae479b65
10 changed files with 142 additions and 58 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"perf:diff": "bin/perf-diff.sh", "perf:diff": "bin/perf-diff.sh",
"perf:engines": "cd benchmark && npm run engines", "perf:engines": "cd benchmark && npm run engines",
"build": "npm run build:dist && npm run build:docs", "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" "build:docs": "bin/build-docs.sh"
}, },
"bin": { "bin": {
+1 -1
View File
@@ -32,7 +32,7 @@ export default {
ctx.setRegister('blockMode', BlockMode.OUTPUT) ctx.setRegister('blockMode', BlockMode.OUTPUT)
const scope = yield hash.render(ctx) const scope = yield hash.render(ctx)
if (withVar) scope[filepath] = evalToken(withVar, 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) ctx.push(scope)
yield renderer.renderTemplates(templates, ctx, emitter) yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop() ctx.pop()
+2 -2
View File
@@ -10,7 +10,7 @@ export default {
const tokenizer = new Tokenizer(token.args, this.liquid.options.operatorsTrie) const tokenizer = new Tokenizer(token.args, this.liquid.options.operatorsTrie)
this['file'] = this.parseFilePath(tokenizer, this.liquid) this['file'] = this.parseFilePath(tokenizer, this.liquid)
this.hash = new Hash(tokenizer.remaining()) 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) { render: function * (ctx: Context, emitter: Emitter) {
const { liquid, hash, file } = this const { liquid, hash, file } = this
@@ -22,7 +22,7 @@ export default {
} }
const filepath = yield this.renderFilePath(this['file'], ctx, liquid) const filepath = yield this.renderFilePath(this['file'], ctx, liquid)
assert(filepath, () => `illegal filename "${filepath}"`) 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 // render remaining contents and store rendered results
ctx.setRegister('blockMode', BlockMode.STORE) ctx.setRegister('blockMode', BlockMode.STORE)
+2 -2
View File
@@ -65,12 +65,12 @@ export default {
scope['forloop'] = new ForloopDrop(collection.length) scope['forloop'] = new ForloopDrop(collection.length)
for (const item of collection) { for (const item of collection) {
scope[alias] = item 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) yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
scope.forloop.next() scope.forloop.next()
} }
} else { } else {
const templates = yield liquid.parseFileImpl(filepath, childCtx.sync) const templates = yield liquid._parsePartialFile(filepath, childCtx.sync)
yield liquid.renderer.renderTemplates(templates, childCtx, emitter) yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
} }
} }
+48
View File
@@ -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
}
}
+24 -1
View File
@@ -11,6 +11,10 @@ import { timezoneOffset } from './util/strftime'
export interface LiquidOptions { 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 `["."]` */ /** 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[]; 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 `""`. */ /** 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; extname?: string;
/** Whether or not to cache resolved templates. Defaults to `false`. */ /** Whether or not to cache resolved templates. Defaults to `false`. */
@@ -61,12 +65,16 @@ export interface LiquidOptions {
interface NormalizedOptions extends LiquidOptions { interface NormalizedOptions extends LiquidOptions {
root?: string[]; root?: string[];
partials?: string[];
layouts?: string[];
cache?: Cache<Template[]>; cache?: Cache<Template[]>;
operatorsTrie?: Trie; operatorsTrie?: Trie;
} }
export interface NormalizedFullOptions extends NormalizedOptions { export interface NormalizedFullOptions extends NormalizedOptions {
root: string[]; root: string[];
partials: string[];
layouts: string[];
extname: string; extname: string;
cache: undefined | Cache<Template[]>; cache: undefined | Cache<Template[]>;
jsTruthy: boolean; jsTruthy: boolean;
@@ -93,6 +101,8 @@ export interface NormalizedFullOptions extends NormalizedOptions {
export const defaultOptions: NormalizedFullOptions = { export const defaultOptions: NormalizedFullOptions = {
root: ['.'], root: ['.'],
layouts: ['.'],
partials: ['.'],
cache: undefined, cache: undefined,
extname: '', extname: '',
fs: fs, fs: fs,
@@ -123,6 +133,12 @@ export function normalize (options?: LiquidOptions): NormalizedOptions {
if (options.hasOwnProperty('root')) { if (options.hasOwnProperty('root')) {
options.root = normalizeStringArray(options.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')) { if (options.hasOwnProperty('cache')) {
let cache: Cache<Template[]> | undefined let cache: Cache<Template[]> | undefined
if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.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 { 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[] { export function normalizeStringArray (value: any): string[] {
+13 -48
View File
@@ -1,7 +1,7 @@
import { Context } from './context/context' import { Context } from './context/context'
import { forOwn, snakeCase } from './util/underscore' import { forOwn, snakeCase } from './util/underscore'
import { Template } from './template/template' import { Template } from './template/template'
import { Tokenizer } from './parser/tokenizer' import { LookupType } from './fs/loader'
import { Render } from './render/render' import { Render } from './render/render'
import Parser from './parser/parser' import Parser from './parser/parser'
import { TagImplOptions } from './template/tag/tag-impl-options' import { TagImplOptions } from './template/tag/tag-impl-options'
@@ -18,12 +18,11 @@ export * from './util/error'
export * from './types' export * from './types'
export class Liquid { export class Liquid {
public options: NormalizedFullOptions public readonly options: NormalizedFullOptions
public renderer: Render public readonly renderer: Render
public parser: Parser public readonly parser: Parser
public filters: FilterMap public readonly filters: FilterMap
public tags: TagMap public readonly tags: TagMap
public parseFileImpl: (file: string, sync?: boolean) => Iterator<Template[]>
public constructor (opts: LiquidOptions = {}) { public constructor (opts: LiquidOptions = {}) {
this.options = applyDefault(normalize(opts)) this.options = applyDefault(normalize(opts))
@@ -31,15 +30,12 @@ export class Liquid {
this.renderer = new Render() this.renderer = new Render()
this.filters = new FilterMap(this.options.strictFilters, this) this.filters = new FilterMap(this.options.strictFilters, this)
this.tags = new TagMap() 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(builtinTags, (conf: TagImplOptions, name: string) => this.registerTag(snakeCase(name), conf))
forOwn(builtinFilters, (handler: FilterImplOptions, name: string) => this.registerFilter(snakeCase(name), handler)) forOwn(builtinFilters, (handler: FilterImplOptions, name: string) => this.registerFilter(snakeCase(name), handler))
} }
public parse (html: string, filepath?: string): Template[] { public parse (html: string, filepath?: string): Template[] {
const tokenizer = new Tokenizer(html, this.options.operatorsTrie, filepath) return this.parser.parse(html, filepath)
const tokens = tokenizer.readTopLevelTokens(this.options)
return this.parser.parse(tokens)
} }
public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator<any> { public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator<any> {
@@ -68,30 +64,17 @@ export class Liquid {
return toValue(this._parseAndRender(html, scope, true)) return toValue(this._parseAndRender(html, scope, true))
} }
private * _parseFileCached (file: string, sync?: boolean) { public _parsePartialFile (file: string, sync?: boolean) {
const cache = this.options.cache! return this.parser.parseFile(file, sync, LookupType.Partials)
let tpls = yield cache.read(file)
if (tpls) return tpls
tpls = yield this._parseFile(file, sync)
cache.write(file, tpls)
return tpls
} }
private * _parseFile (file: string, sync?: boolean) { public _parseLayoutFile (file: string, sync?: boolean) {
const { fs, root } = this.options return this.parser.parseFile(file, sync, LookupType.Layouts)
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 async parseFile (file: string): Promise<Template[]> { 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[] { 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) { public async renderFile (file: string, ctx?: object) {
const templates = await this.parseFile(file) 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) 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
}
} }
+32 -2
View File
@@ -1,5 +1,5 @@
import { ParseError } from '../util/error' import { ParseError } from '../util/error'
import { Liquid } from '../liquid' import { Liquid, Tokenizer } from '../liquid'
import { ParseStream } from './parse-stream' import { ParseStream } from './parse-stream'
import { isTagToken, isOutputToken } from '../util/type-guards' import { isTagToken, isOutputToken } from '../util/type-guards'
import { OutputToken } from '../tokens/output-token' import { OutputToken } from '../tokens/output-token'
@@ -8,14 +8,31 @@ import { Output } from '../template/output'
import { HTML } from '../template/html' import { HTML } from '../template/html'
import { Template } from '../template/template' import { Template } from '../template/template'
import { TopLevelToken } from '../tokens/toplevel-token' 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 { export default class Parser {
public parseFile: (file: string, sync?: boolean, type?: LookupType) => Iterator<Template[]>
private liquid: Liquid private liquid: Liquid
private fs: FS
private cache: Cache<Template[]> | undefined
private loader: Loader
public constructor (liquid: Liquid) { public constructor (liquid: Liquid) {
this.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 (tokens: TopLevelToken[]) { 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 parseTokens (tokens: TopLevelToken[]) {
let token let token
const templates: Template[] = [] const templates: Template[] = []
while ((token = tokens.shift())) { while ((token = tokens.shift())) {
@@ -39,4 +56,17 @@ export default class Parser {
public parseStream (tokens: TopLevelToken[]) { public parseStream (tokens: TopLevelToken[]) {
return new ParseStream(tokens, (token, tokens) => this.parseToken(token, tokens)) 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)
}
} }
+10 -1
View File
@@ -76,6 +76,15 @@ describe('tags/layout', function () {
const html = await liquid.parseAndRender(src) const html = await liquid.parseAndRender(src)
return expect(html).to.equal('XAYBZ') 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 () { it('should support block.super', async function () {
mock({ mock({
'/parent.html': '{% block css %}<link href="base.css" rel="stylesheet">{% endblock %}' '/parent.html': '{% block css %}<link href="base.css" rel="stylesheet">{% endblock %}'
@@ -171,7 +180,7 @@ describe('tags/layout', function () {
}) })
describe('static partial', function () { describe('static partial', function () {
it('should support filename with extention', async function () { it('should support filename with extension', async function () {
mock({ mock({
'/parent.html': '{{color}}{%block%}{%endblock%}', '/parent.html': '{{color}}{%block%}{%endblock%}',
'/main.html': '{% layout parent.html color:"black"%}{%block%}A{%endblock%}' '/main.html': '{% layout parent.html color:"black"%}{%block%}A{%endblock%}'
+9
View File
@@ -21,6 +21,15 @@ describe('tags/render', function () {
const html = await liquid.renderFile('/current.html') const html = await liquid.renderFile('/current.html')
expect(html).to.equal('barfoobar') 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 () { it('should support template string', async function () {
mock({ mock({
'/current.html': 'bar{% render "bar/{{name}}" %}bar', '/current.html': 'bar{% render "bar/{{name}}" %}bar',