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:
Harttle
2021-10-06 17:36:37 +08:00
parent 24a19c092a
commit a3455ebd0b
15 changed files with 160 additions and 31 deletions
+12 -4
View File
@@ -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. 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 ## 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`: **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 ```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. 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 %} {% 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
**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. **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.
+14 -6
View File
@@ -15,13 +15,25 @@ const engine = new Liquid({
下面的所有选项的概述,希望了解具体的类型和签名,请前往 <a href="https://liquidjs.com/api/interfaces/liquid_options_.liquidoptions.html" target="_self">LiquidOptions | API</a>. 下面的所有选项的概述,希望了解具体的类型和签名,请前往 <a href="https://liquidjs.com/api/interfaces/liquid_options_.liquidoptions.html" target="_self">LiquidOptions | API</a>.
{% endnote %} {% endnote %}
## cache ## 缓存
**cache** 用来指定是否缓存曾经读取和处理过的模板来提升性能。在生产环境模板会重复渲染的情况会很有用。 **cache** 用来指定是否缓存曾经读取和处理过的模板来提升性能。在生产环境模板会重复渲染的情况会很有用。
默认是 `false`,当设置为 `true` 时会启用一个大小为 1024 的 LRU 缓存。当然也可以传一个数字来指定缓存大小。此外还可以是一个自定义的缓存实现,LiquidJS 会通过它来查找和读写文件。详情请参考 [Caching][caching]。 默认是 `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` **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>。 在 2.0.1 之前,<code>extname</code> 默认值为 `.liquid`。要禁用它需要明确设置为 <code>extname: ''</code>。详情参考 <a href="https://github.com/harttle/liquidjs/issues/41" target="_blank">#41</a>。
{% endnote %} {% endnote %}
## root
**root** 用来指定 LiquidJS 查找和读取模板的根目录。可以是单个字符串,也可以是一个数组 LiquidJS 会顺序查找。详情请参考 [Render Files][render-file]。
## fs ## fs
**fs** 用来自定义文件系统实现,详情请参考 [Abstract File System][abstract-fs]。 **fs** 用来自定义文件系统实现,详情请参考 [Abstract File System][abstract-fs]。
+2 -1
View File
@@ -9,6 +9,7 @@ export default {
const args = token.args const args = token.args
const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie) const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie)
this['file'] = this.parseFilePath(tokenizer, this.liquid) this['file'] = this.parseFilePath(tokenizer, this.liquid)
this['currentFile'] = token.file
const begin = tokenizer.p const begin = tokenizer.p
const withStr = tokenizer.readIdentifier() const withStr = tokenizer.readIdentifier()
@@ -32,7 +33,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._parsePartialFile(filepath, ctx.sync) const templates = yield liquid._parsePartialFile(filepath, ctx.sync, this['currentFile'])
ctx.push(scope) ctx.push(scope)
yield renderer.renderTemplates(templates, ctx, emitter) yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop() ctx.pop()
+2 -1
View File
@@ -9,6 +9,7 @@ export default {
parse: function (token: TagToken, remainTokens: TopLevelToken[]) { parse: function (token: TagToken, remainTokens: TopLevelToken[]) {
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['currentFile'] = token.file
this.hash = new Hash(tokenizer.remaining()) this.hash = new Hash(tokenizer.remaining())
this.tpls = this.liquid.parser.parseTokens(remainTokens) this.tpls = this.liquid.parser.parseTokens(remainTokens)
}, },
@@ -22,7 +23,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._parseLayoutFile(filepath, ctx.sync) const templates = yield liquid._parseLayoutFile(filepath, ctx.sync, this['currentFile'])
// render remaining contents and store rendered results // render remaining contents and store rendered results
ctx.setRegister('blockMode', BlockMode.STORE) ctx.setRegister('blockMode', BlockMode.STORE)
+3 -3
View File
@@ -11,7 +11,7 @@ export default {
const args = token.args const args = token.args
const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie) const tokenizer = new Tokenizer(args, this.liquid.options.operatorsTrie)
this['file'] = this.parseFilePath(tokenizer, this.liquid) this['file'] = this.parseFilePath(tokenizer, this.liquid)
this['currentFile'] = token.file
while (!tokenizer.end()) { while (!tokenizer.end()) {
tokenizer.skipBlank() tokenizer.skipBlank()
const begin = tokenizer.p const begin = tokenizer.p
@@ -65,12 +65,12 @@ export default {
scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias) scope['forloop'] = new ForloopDrop(collection.length, value.getText(), alias)
for (const item of collection) { for (const item of collection) {
scope[alias] = item 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) yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
scope.forloop.next() scope.forloop.next()
} }
} else { } 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) yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
} }
} }
+32 -4
View File
@@ -6,6 +6,7 @@ interface LoaderOptions {
root: string[]; root: string[];
partials: string[]; partials: string[];
layouts: string[]; layouts: string[];
relativeReference: boolean;
} }
export enum LookupType { export enum LookupType {
Partials = 'partials', Partials = 'partials',
@@ -19,19 +20,40 @@ export class Loader {
this.options = options 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 { fs } = this.options
const dirs = this.options[type] 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 if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
} }
throw this.lookupError(file, dirs) 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 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) { 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) { if (fs.fallback !== undefined) {
const filepath = fs.fallback(file) 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[]) { private lookupError (file: string, roots: string[]) {
const err = new Error('ENOENT') as any const err = new Error('ENOENT') as any
err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"` err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"`
+4
View File
@@ -14,6 +14,8 @@ export interface LiquidOptions {
partials?: string | string[]; 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` */ /** 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[]; 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 `""`. */ /** 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`. */
@@ -74,6 +76,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
root: string[]; root: string[];
partials: string[]; partials: string[];
layouts: string[]; layouts: string[];
relativeReference: boolean;
extname: string; extname: string;
cache: undefined | Cache<Template[]>; cache: undefined | Cache<Template[]>;
jsTruthy: boolean; jsTruthy: boolean;
@@ -102,6 +105,7 @@ export const defaultOptions: NormalizedFullOptions = {
root: ['.'], root: ['.'],
layouts: ['.'], layouts: ['.'],
partials: ['.'], partials: ['.'],
relativeReference: true,
cache: undefined, cache: undefined,
extname: '', extname: '',
fs: fs, fs: fs,
+4 -4
View File
@@ -64,11 +64,11 @@ export class Liquid {
return toValue(this._parseAndRender(html, scope, true)) return toValue(this._parseAndRender(html, scope, true))
} }
public _parsePartialFile (file: string, sync?: boolean) { public _parsePartialFile (file: string, sync?: boolean, currentFile?: string) {
return this.parser.parseFile(file, sync, LookupType.Partials) return this.parser.parseFile(file, sync, LookupType.Partials, currentFile)
} }
public _parseLayoutFile (file: string, sync?: boolean) { public _parseLayoutFile (file: string, sync?: boolean, currentFile?: string) {
return this.parser.parseFile(file, sync, LookupType.Layouts) return this.parser.parseFile(file, sync, LookupType.Layouts, currentFile)
} }
public async parseFile (file: string): Promise<Template[]> { public async parseFile (file: string): Promise<Template[]> {
return toPromise(this.parser.parseFile(file, false)) return toPromise(this.parser.parseFile(file, false))
+8 -6
View File
@@ -13,7 +13,7 @@ import { Loader, LookupType } from '../fs/loader'
import { FS } from '../fs/fs' import { FS } from '../fs/fs'
export default class Parser { 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 liquid: Liquid
private fs: FS private fs: FS
@@ -56,17 +56,19 @@ 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) { private * _parseFileCached (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string) {
const key = type + ':' + file const key = this.loader.shouldLoadRelative(file)
? currentFile + ',' + file
: type + ':' + file
let templates = yield this.cache!.read(key) let templates = yield this.cache!.read(key)
if (templates) return templates if (templates) return templates
templates = yield this._parseFile(file, sync) templates = yield this._parseFile(file, sync, type, currentFile)
this.cache!.write(key, templates) this.cache!.write(key, templates)
return templates return templates
} }
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root) { private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string) {
const filepath = yield this.loader.lookup(file, type, sync) 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) return this.liquid.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
} }
} }
+8
View File
@@ -19,6 +19,14 @@ describe('tags/include', function () {
const html = await liquid.renderFile('/current.html') const html = await liquid.renderFile('/current.html')
return expect(html).to.equal('barfoobar') 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 () { it('should support template string', async function () {
mock({ mock({
'/current.html': 'bar{% include "bar/{{name}}" %}bar', '/current.html': 'bar{% include "bar/{{name}}" %}bar',
+10
View File
@@ -179,6 +179,16 @@ describe('tags/layout', function () {
return expect(html).to.equal('blackredA') 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 () { describe('static partial', function () {
it('should support filename with extension', async function () { it('should support filename with extension', async function () {
mock({ mock({
+26 -1
View File
@@ -26,7 +26,7 @@ describe('tags/render', function () {
'/current.html': 'bar{% render "foo.html" %}bar', '/current.html': 'bar{% render "foo.html" %}bar',
'/partials/foo.html': 'foo' '/partials/foo.html': 'foo'
}) })
const liquid = new Liquid({ partials: '/partials' }) const liquid = new Liquid({ partials: '/partials', root: '/' })
const html = await liquid.renderFile('/current.html') const html = await liquid.renderFile('/current.html')
expect(html).to.equal('barfoobar') expect(html).to.equal('barfoobar')
}) })
@@ -223,6 +223,31 @@ describe('tags/render', function () {
const html = await liquid.renderFile('personInfo.html', ctx) const html = await liquid.renderFile('personInfo.html', ctx)
expect(html).to.equal('This is a person <p>Joe Shmoe<br/>City: Dallas</p>') 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 () { describe('static partial', function () {
it('should support filename with extention', async function () { it('should support filename with extention', async function () {
+18
View File
@@ -174,5 +174,23 @@ describe('LiquidOptions#cache', function () {
const y = await engine.renderFile('foo') const y = await engine.renderFile('foo')
expect(y).to.equal('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')
})
}) })
}) })
+16
View File
@@ -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')
})
})
})
+1 -1
View File
@@ -5,7 +5,7 @@ import * as chaiAsPromised from 'chai-as-promised'
use(chaiAsPromised) use(chaiAsPromised)
describe('fs', function () { describe('fs/node', function () {
describe('.resolve()', function () { describe('.resolve()', function () {
it('should resolve based on root', async function () { it('should resolve based on root', async function () {
const filepath = fs.resolve('/foo', 'bar.html', '.liquid') const filepath = fs.resolve('/foo', 'bar.html', '.liquid')