mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-14 20:00:39 -07:00
feat: relativeReference for render/include/layout, #395
- `relativeReference` is enabled by default, set to `false` to disable
- Referenced files are still constrained within root/partias/layouts
- fix: relative filenames are not constrained (which allows arbitrary filesystem read)
Example Usage:
{% render "../foo/bar.html" %}
Note:
../foo/bar.html' should also be within `partials` (or `root` if `partials` not set)
This commit is contained in:
@@ -23,8 +23,20 @@ It's default to `false`. When setting to `true` a default LRU cache of size 1024
|
||||
|
||||
Additionally, it can also be a custom cache implementation. See [Caching][caching] for details.
|
||||
|
||||
## Partials/Layouts
|
||||
|
||||
**root** is used to specify template directories for LiquidJS to lookup and read template files. Can be a single string and an array of strings. See [Render Files][render-file] for details.
|
||||
|
||||
**layouts** is used to specify template directories for LiquidJS to lookup files for `{% layout %}`. Same format as `root` and will default to `root` if not specified.
|
||||
|
||||
**partials** is used to specify template directories for LiquidJS to lookup files for `{% render %}` and `{% include %}`. Same format as `root` and will default to `root` if not specified.
|
||||
|
||||
**relativeReference** is set to `true` by default to allow relative filenames. Note that relatively referenced files are also need to be within corresponding root. For example you can reference another file like `{% render ../foo/bar %}` as long as `../foo/bar` is also within `partials` directory.
|
||||
|
||||
## dynamicPartials
|
||||
|
||||
> Note: for historical reasons, it's named dynamicPartials but it also works for layouts.
|
||||
|
||||
**dynamicPartials** indicates whether or not to treat filename arguments in [include][include], [render][render], [layout][layout] tags as a variable. Defaults to `true`. For example, render the following snippet with scope `{ file: 'foo.html' }` will include the `foo.html`:
|
||||
|
||||
```liquid
|
||||
@@ -53,10 +65,6 @@ LiquidJS defaults this option to <code>true</code> to be compatible with shopify
|
||||
Before 2.0.1, <code>extname</code> is set to `.liquid` by default. To change that you need to set <code>extname: ''</code> explicitly. See <a href="https://github.com/harttle/liquidjs/issues/41" target="_blank">#41</a> for details.
|
||||
{% endnote %}
|
||||
|
||||
## root
|
||||
|
||||
**root** is used to specify template directories for LiquidJS to lookup and read template files. Can be a single string and an array of strings. See [Render Files][render-file] for details.
|
||||
|
||||
## fs
|
||||
|
||||
**fs** is used to define a custom file system implementation which will be used by LiquidJS to lookup and read template files. See [Abstract File System][abstract-fs] for details.
|
||||
|
||||
@@ -15,13 +15,25 @@ const engine = new Liquid({
|
||||
下面的所有选项的概述,希望了解具体的类型和签名,请前往 <a href="https://liquidjs.com/api/interfaces/liquid_options_.liquidoptions.html" target="_self">LiquidOptions | API</a>.
|
||||
{% endnote %}
|
||||
|
||||
## cache
|
||||
## 缓存
|
||||
|
||||
**cache** 用来指定是否缓存曾经读取和处理过的模板来提升性能。在生产环境模板会重复渲染的情况会很有用。
|
||||
|
||||
默认是 `false`,当设置为 `true` 时会启用一个大小为 1024 的 LRU 缓存。当然也可以传一个数字来指定缓存大小。此外还可以是一个自定义的缓存实现,LiquidJS 会通过它来查找和读写文件。详情请参考 [Caching][caching]。
|
||||
|
||||
## dynamicPartials
|
||||
## 布局和片段
|
||||
|
||||
**root** 用来指定 LiquidJS 查找和读取模板的根目录。可以是单个字符串,也可以是一个数组 LiquidJS 会顺序查找。详情请参考 [Render Files][render-file]。
|
||||
|
||||
**layouts** 和 `root` 具有一样的格式,用来指定 `{% layout %}` 所使用的目录。没有指定时默认为 `root`。
|
||||
|
||||
**partials** 和 `root` 具有一样的格式,用来指定 `{% render %}` 和 `{% include %}` 所使用的目录。没有指定时默认为 `root`。
|
||||
|
||||
**relativeReference** 默认为 `true` 用来允许以相对路径引用其他文件。注意被引用的文件仍然需要在对应的 root 目录下。例如可以这样引用一个文件 `{% render ../foo/bar %}`,但需要确保 `../foo/bar` 处于 `partials` 目录下。
|
||||
|
||||
## 动态引用
|
||||
|
||||
> 注意由于历史原因这个选项叫做 dynamicPartials,但它对 layout 也起作用。
|
||||
|
||||
**dynamicPartials** 表示是否把传给 [include][include], [render][render], [layout][layout] 标签的文件名当做变量处理。默认为 `true`。例如用上下文 `{ file: 'foo.html' }` 渲染下面的模板将会引入文件 `foo.html`:
|
||||
|
||||
@@ -51,10 +63,6 @@ LiquidJS 把这个选项默认值设为 <code>true</code> 以兼容于 shopify/l
|
||||
在 2.0.1 之前,<code>extname</code> 默认值为 `.liquid`。要禁用它需要明确设置为 <code>extname: ''</code>。详情参考 <a href="https://github.com/harttle/liquidjs/issues/41" target="_blank">#41</a>。
|
||||
{% endnote %}
|
||||
|
||||
## root
|
||||
|
||||
**root** 用来指定 LiquidJS 查找和读取模板的根目录。可以是单个字符串,也可以是一个数组 LiquidJS 会顺序查找。详情请参考 [Render Files][render-file]。
|
||||
|
||||
## fs
|
||||
|
||||
**fs** 用来自定义文件系统实现,详情请参考 [Abstract File System][abstract-fs]。
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
const args = token.args
|
||||
const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie)
|
||||
this['file'] = this.parseFilePath(tokenizer, this.liquid)
|
||||
this['currentFile'] = token.file
|
||||
|
||||
const begin = tokenizer.p
|
||||
const withStr = tokenizer.readIdentifier()
|
||||
@@ -32,7 +33,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._parsePartialFile(filepath, ctx.sync)
|
||||
const templates = yield liquid._parsePartialFile(filepath, ctx.sync, this['currentFile'])
|
||||
ctx.push(scope)
|
||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
parse: function (token: TagToken, remainTokens: TopLevelToken[]) {
|
||||
const tokenizer = new Tokenizer(token.args, this.liquid.options.operatorsTrie)
|
||||
this['file'] = this.parseFilePath(tokenizer, this.liquid)
|
||||
this['currentFile'] = token.file
|
||||
this.hash = new Hash(tokenizer.remaining())
|
||||
this.tpls = this.liquid.parser.parseTokens(remainTokens)
|
||||
},
|
||||
@@ -22,7 +23,7 @@ export default {
|
||||
}
|
||||
const filepath = yield this.renderFilePath(this['file'], ctx, liquid)
|
||||
assert(filepath, () => `illegal filename "${filepath}"`)
|
||||
const templates = yield liquid._parseLayoutFile(filepath, ctx.sync)
|
||||
const templates = yield liquid._parseLayoutFile(filepath, ctx.sync, this['currentFile'])
|
||||
|
||||
// render remaining contents and store rendered results
|
||||
ctx.setRegister('blockMode', BlockMode.STORE)
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
const args = token.args
|
||||
const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie)
|
||||
this['file'] = this.parseFilePath(tokenizer, this.liquid)
|
||||
|
||||
this['currentFile'] = token.file
|
||||
while (!tokenizer.end()) {
|
||||
tokenizer.skipBlank()
|
||||
const begin = tokenizer.p
|
||||
@@ -65,12 +65,12 @@ export default {
|
||||
scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias)
|
||||
for (const item of collection) {
|
||||
scope[alias] = item
|
||||
const templates = yield liquid._parsePartialFile(filepath, childCtx.sync)
|
||||
const templates = yield liquid._parsePartialFile(filepath, childCtx.sync, this['currentFile'])
|
||||
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
||||
scope.forloop.next()
|
||||
}
|
||||
} else {
|
||||
const templates = yield liquid._parsePartialFile(filepath, childCtx.sync)
|
||||
const templates = yield liquid._parsePartialFile(filepath, childCtx.sync, this['currentFile'])
|
||||
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -6,6 +6,7 @@ interface LoaderOptions {
|
||||
root: string[];
|
||||
partials: string[];
|
||||
layouts: string[];
|
||||
relativeReference: boolean;
|
||||
}
|
||||
export enum LookupType {
|
||||
Partials = 'partials',
|
||||
@@ -19,19 +20,40 @@ export class Loader {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
public * lookup (file: string, type: LookupType, sync?: boolean) {
|
||||
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string) {
|
||||
const { fs } = this.options
|
||||
const dirs = this.options[type]
|
||||
for (const filepath of this.candidates(file, dirs)) {
|
||||
for (const filepath of this.candidates(file, dirs, currentFile)) {
|
||||
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
|
||||
}
|
||||
throw this.lookupError(file, dirs)
|
||||
}
|
||||
|
||||
private * candidates (file: string, dirs: string[]) {
|
||||
public shouldLoadRelative (currentFile: string) {
|
||||
return this.options.relativeReference && this.isRelativePath(currentFile)
|
||||
}
|
||||
|
||||
public isRelativePath (path: string) {
|
||||
return path.startsWith('./') || path.startsWith('../')
|
||||
}
|
||||
|
||||
public * candidates (file: string, dirs: string[], currentFile?: string) {
|
||||
const { fs, extname } = this.options
|
||||
if (this.shouldLoadRelative(file) && currentFile) {
|
||||
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
|
||||
for (const dir of dirs) {
|
||||
if (referenced.startsWith(dir)) {
|
||||
// the relatively referenced file is within one of root dirs
|
||||
yield referenced
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
yield fs.resolve(dir, file, extname)
|
||||
const referenced = fs.resolve(dir, file, extname)
|
||||
if (referenced.startsWith(dir)) {
|
||||
yield referenced
|
||||
}
|
||||
}
|
||||
if (fs.fallback !== undefined) {
|
||||
const filepath = fs.fallback(file)
|
||||
@@ -39,6 +61,12 @@ export class Loader {
|
||||
}
|
||||
}
|
||||
|
||||
private dirname (path: string) {
|
||||
const segments = path.split('/')
|
||||
segments.pop()
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
private lookupError (file: string, roots: string[]) {
|
||||
const err = new Error('ENOENT') as any
|
||||
err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"`
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface LiquidOptions {
|
||||
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[];
|
||||
/** Allow refer to layouts/partials by relative pathname. To avoid arbitrary filesystem read, paths been referenced also need to be within corresponding root, partials, layouts. Defaults to `true`. */
|
||||
relativeReference?: boolean;
|
||||
/** 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`. */
|
||||
@@ -74,6 +76,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
|
||||
root: string[];
|
||||
partials: string[];
|
||||
layouts: string[];
|
||||
relativeReference: boolean;
|
||||
extname: string;
|
||||
cache: undefined | Cache<Template[]>;
|
||||
jsTruthy: boolean;
|
||||
@@ -102,6 +105,7 @@ export const defaultOptions: NormalizedFullOptions = {
|
||||
root: ['.'],
|
||||
layouts: ['.'],
|
||||
partials: ['.'],
|
||||
relativeReference: true,
|
||||
cache: undefined,
|
||||
extname: '',
|
||||
fs: fs,
|
||||
|
||||
+4
-4
@@ -64,11 +64,11 @@ export class Liquid {
|
||||
return toValue(this._parseAndRender(html, scope, true))
|
||||
}
|
||||
|
||||
public _parsePartialFile (file: string, sync?: boolean) {
|
||||
return this.parser.parseFile(file, sync, LookupType.Partials)
|
||||
public _parsePartialFile (file: string, sync?: boolean, currentFile?: string) {
|
||||
return this.parser.parseFile(file, sync, LookupType.Partials, currentFile)
|
||||
}
|
||||
public _parseLayoutFile (file: string, sync?: boolean) {
|
||||
return this.parser.parseFile(file, sync, LookupType.Layouts)
|
||||
public _parseLayoutFile (file: string, sync?: boolean, currentFile?: string) {
|
||||
return this.parser.parseFile(file, sync, LookupType.Layouts, currentFile)
|
||||
}
|
||||
public async parseFile (file: string): Promise<Template[]> {
|
||||
return toPromise(this.parser.parseFile(file, false))
|
||||
|
||||
@@ -13,7 +13,7 @@ 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[]>
|
||||
public parseFile: (file: string, sync?: boolean, type?: LookupType, currentFile?: string) => Iterator<Template[]>
|
||||
|
||||
private liquid: Liquid
|
||||
private fs: FS
|
||||
@@ -56,17 +56,19 @@ 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
|
||||
private * _parseFileCached (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string) {
|
||||
const key = this.loader.shouldLoadRelative(file)
|
||||
? currentFile + ',' + file
|
||||
: type + ':' + file
|
||||
let templates = yield this.cache!.read(key)
|
||||
if (templates) return templates
|
||||
|
||||
templates = yield this._parseFile(file, sync)
|
||||
templates = yield this._parseFile(file, sync, type, currentFile)
|
||||
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)
|
||||
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string) {
|
||||
const filepath = yield this.loader.lookup(file, type, sync, currentFile)
|
||||
return this.liquid.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ describe('tags/include', function () {
|
||||
const html = await liquid.renderFile('/current.html')
|
||||
return expect(html).to.equal('barfoobar')
|
||||
})
|
||||
it('should support relative reference', async function () {
|
||||
mock({
|
||||
'/foo/bar/current.html': 'bar{% include "../coo/foo.html" %}bar',
|
||||
'/foo/coo/foo.html': 'foo'
|
||||
})
|
||||
const html = await liquid.renderFile('/foo/bar/current.html')
|
||||
return expect(html).to.equal('barfoobar')
|
||||
})
|
||||
it('should support template string', async function () {
|
||||
mock({
|
||||
'/current.html': 'bar{% include "bar/{{name}}" %}bar',
|
||||
|
||||
@@ -179,6 +179,16 @@ describe('tags/layout', function () {
|
||||
return expect(html).to.equal('blackredA')
|
||||
})
|
||||
|
||||
it('should support relative reference', async function () {
|
||||
mock({
|
||||
'/foo/bar/parent.html': '{{color}}{%block%}{%endblock%}',
|
||||
'/foo/bar/main.html': '{% layout ./parent.html color:"black"%}{%block%}A{%endblock%}'
|
||||
})
|
||||
const staticLiquid = new Liquid({ root: '/', dynamicPartials: false })
|
||||
const html = await staticLiquid.renderFile('/foo/bar/main.html')
|
||||
return expect(html).to.equal('blackA')
|
||||
})
|
||||
|
||||
describe('static partial', function () {
|
||||
it('should support filename with extension', async function () {
|
||||
mock({
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('tags/render', function () {
|
||||
'/current.html': 'bar{% render "foo.html" %}bar',
|
||||
'/partials/foo.html': 'foo'
|
||||
})
|
||||
const liquid = new Liquid({ partials: '/partials' })
|
||||
const liquid = new Liquid({ partials: '/partials', root: '/' })
|
||||
const html = await liquid.renderFile('/current.html')
|
||||
expect(html).to.equal('barfoobar')
|
||||
})
|
||||
@@ -223,6 +223,31 @@ describe('tags/render', function () {
|
||||
const html = await liquid.renderFile('personInfo.html', ctx)
|
||||
expect(html).to.equal('This is a person <p>Joe Shmoe<br/>City: Dallas</p>')
|
||||
})
|
||||
it('should support relative reference', async function () {
|
||||
mock({
|
||||
'/foo/coo/parent.html': 'X{% render ../bar/child.html, color:"red" %}Y',
|
||||
'/foo/bar/child.html': 'child with {{color}}'
|
||||
})
|
||||
const staticLiquid = new Liquid({ dynamicPartials: false, root: '/foo' })
|
||||
const html = await staticLiquid.renderFile('coo/parent.html')
|
||||
expect(html).to.equal('Xchild with redY')
|
||||
})
|
||||
it('should disable relative reference if specified', () => {
|
||||
mock({
|
||||
'/foo/coo/parent.html': 'X{% render ../bar/child.html, color:"red" %}Y',
|
||||
'/foo/bar/child.html': 'child with {{color}}'
|
||||
})
|
||||
const staticLiquid = new Liquid({ dynamicPartials: false, root: '/foo', relativeReference: false })
|
||||
return expect(staticLiquid.renderFile('coo/parent.html')).to.be.rejectedWith(/Failed to lookup/)
|
||||
})
|
||||
it('should throw not found if relative reference out of root', () => {
|
||||
mock({
|
||||
'/foo/parent.html': 'X{% render ../bar/child.html, color:"red" %}Y',
|
||||
'/bar/child.html': 'child with {{color}}'
|
||||
})
|
||||
const staticLiquid = new Liquid({ dynamicPartials: false, root: '/foo', partials: '/foo' })
|
||||
return expect(staticLiquid.renderFile('parent.html')).to.be.rejectedWith(/Failed to lookup "..\/bar\/child.html"/)
|
||||
})
|
||||
|
||||
describe('static partial', function () {
|
||||
it('should support filename with extention', async function () {
|
||||
|
||||
@@ -174,5 +174,23 @@ describe('LiquidOptions#cache', function () {
|
||||
const y = await engine.renderFile('foo')
|
||||
expect(y).to.equal('foo')
|
||||
})
|
||||
it('should cache relative referenced files properly', async function () {
|
||||
const engine = new Liquid({
|
||||
root: '/root/',
|
||||
extname: '.html',
|
||||
cache: true
|
||||
})
|
||||
mock({
|
||||
'/root/foo.html': '{% render "./bar" %}',
|
||||
'/root/bar.html': 'bar1',
|
||||
'/root/another/foo.html': '{% render "./bar" %}',
|
||||
'/root/another/bar.html': 'bar2'
|
||||
})
|
||||
const foo1 = await engine.renderFile('foo')
|
||||
expect(foo1).to.equal('bar1')
|
||||
|
||||
const foo2 = await engine.renderFile('another/foo')
|
||||
expect(foo2).to.equal('bar2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, use } from 'chai'
|
||||
import * as fs from '../../../src/fs/node'
|
||||
import * as chaiAsPromised from 'chai-as-promised'
|
||||
import { Loader } from '../../../src/fs/loader'
|
||||
|
||||
use(chaiAsPromised)
|
||||
|
||||
describe('fs/loader', function () {
|
||||
describe('.candidates()', function () {
|
||||
it('should break once found', async function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
|
||||
expect(candidates.join()).to.equal('/root/foo/bar')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import * as chaiAsPromised from 'chai-as-promised'
|
||||
|
||||
use(chaiAsPromised)
|
||||
|
||||
describe('fs', function () {
|
||||
describe('fs/node', function () {
|
||||
describe('.resolve()', function () {
|
||||
it('should resolve based on root', async function () {
|
||||
const filepath = fs.resolve('/foo', 'bar.html', '.liquid')
|
||||
|
||||
Reference in New Issue
Block a user