true to be compatible with shopify
Before 2.0.1, extname is set to `.liquid` by default. To change that you need to set extname: '' explicitly. See #41 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.
diff --git a/docs/source/zh-cn/tutorials/options.md b/docs/source/zh-cn/tutorials/options.md
index 25362e939..2fa2f16e3 100644
--- a/docs/source/zh-cn/tutorials/options.md
+++ b/docs/source/zh-cn/tutorials/options.md
@@ -15,13 +15,25 @@ const engine = new Liquid({
下面的所有选项的概述,希望了解具体的类型和签名,请前往 LiquidOptions | API.
{% 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 把这个选项默认值设为 true 以兼容于 shopify/l
在 2.0.1 之前,extname 默认值为 `.liquid`。要禁用它需要明确设置为 extname: ''。详情参考 #41。
{% endnote %}
-## root
-
-**root** 用来指定 LiquidJS 查找和读取模板的根目录。可以是单个字符串,也可以是一个数组 LiquidJS 会顺序查找。详情请参考 [Render Files][render-file]。
-
## fs
**fs** 用来自定义文件系统实现,详情请参考 [Abstract File System][abstract-fs]。
diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts
index b3954f492..fd1538a41 100644
--- a/src/builtin/tags/include.ts
+++ b/src/builtin/tags/include.ts
@@ -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()
diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts
index b36efbddf..87c0a2024 100644
--- a/src/builtin/tags/layout.ts
+++ b/src/builtin/tags/layout.ts
@@ -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)
diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts
index a6e805d31..150e589cd 100644
--- a/src/builtin/tags/render.ts
+++ b/src/builtin/tags/render.ts
@@ -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)
}
}
diff --git a/src/fs/loader.ts b/src/fs/loader.ts
index 41632fd1c..81bcc589a 100644
--- a/src/fs/loader.ts
+++ b/src/fs/loader.ts
@@ -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}"`
diff --git a/src/liquid-options.ts b/src/liquid-options.ts
index cbe2bd59d..ef92fda89 100644
--- a/src/liquid-options.ts
+++ b/src/liquid-options.ts
@@ -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;
jsTruthy: boolean;
@@ -102,6 +105,7 @@ export const defaultOptions: NormalizedFullOptions = {
root: ['.'],
layouts: ['.'],
partials: ['.'],
+ relativeReference: true,
cache: undefined,
extname: '',
fs: fs,
diff --git a/src/liquid.ts b/src/liquid.ts
index e9c292910..286f9649f 100644
--- a/src/liquid.ts
+++ b/src/liquid.ts
@@ -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 {
return toPromise(this.parser.parseFile(file, false))
diff --git a/src/parser/parser.ts b/src/parser/parser.ts
index 294e62f18..daa91b82f 100644
--- a/src/parser/parser.ts
+++ b/src/parser/parser.ts
@@ -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
+ public parseFile: (file: string, sync?: boolean, type?: LookupType, currentFile?: string) => Iterator
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)
}
}
diff --git a/test/integration/builtin/tags/include.ts b/test/integration/builtin/tags/include.ts
index a40d94743..b7ccc13a8 100644
--- a/test/integration/builtin/tags/include.ts
+++ b/test/integration/builtin/tags/include.ts
@@ -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',
diff --git a/test/integration/builtin/tags/layout.ts b/test/integration/builtin/tags/layout.ts
index 7e2e104d2..f3a084a38 100644
--- a/test/integration/builtin/tags/layout.ts
+++ b/test/integration/builtin/tags/layout.ts
@@ -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({
diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts
index 4f7637639..e91d08b9f 100644
--- a/test/integration/builtin/tags/render.ts
+++ b/test/integration/builtin/tags/render.ts
@@ -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 Joe Shmoe
City: Dallas