diff --git a/docs/source/tutorials/render-file.md b/docs/source/tutorials/render-file.md
index f7885607b..4ee1e63d8 100644
--- a/docs/source/tutorials/render-file.md
+++ b/docs/source/tutorials/render-file.md
@@ -100,6 +100,23 @@ var engine = new Liquid({
{% note warn Path Traversal Vulnerability %}The default value of contains() always returns true. That means when specifying an abstract file system, you'll need to provide a proper contains() to avoid expose such vulnerabilities.{% endnote %}
+## In-memory Template
+
+To facilitate rendering w/o files, there's a `templates` option to specify a mapping of filenames and their content. LiquidJS will read templates from the mapping.
+
+```typescript
+const engine = new Liquid({
+ templates: {
+ 'views/entry': 'header {% include "../partials/footer" %}',
+ 'partials/footer': 'footer'
+ }
+})
+engine.renderFileSync('views/entry'))
+// Result: 'header footer'
+```
+
+Note that file system options like `root`, `layouts`, `partials`, `relativeReference` will be ignored when `templates` is specified.
+
[fs]: /api/interfaces/LiquidOptions.html#fs
[ifs]: /api/interfaces/FS.html
[fs-node]: https://github.com/harttle/liquidjs/blob/master/src/fs/fs-impl.ts
diff --git a/src/fs/map-fs.spec.ts b/src/fs/map-fs.spec.ts
new file mode 100644
index 000000000..269318d26
--- /dev/null
+++ b/src/fs/map-fs.spec.ts
@@ -0,0 +1,17 @@
+import { MapFS } from './map-fs'
+
+describe('MapFS', () => {
+ const fs = new MapFS({})
+ it('should resolve relative file paths', () => {
+ expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
+ })
+ it('should resolve to parent', () => {
+ expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
+ })
+ it('should resolve to root', () => {
+ expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
+ })
+ it('should resolve exceeding root', () => {
+ expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
+ })
+})
diff --git a/src/fs/map-fs.ts b/src/fs/map-fs.ts
new file mode 100644
index 000000000..8783b14cc
--- /dev/null
+++ b/src/fs/map-fs.ts
@@ -0,0 +1,43 @@
+import { isNil } from '../util'
+
+export class MapFS {
+ constructor (private mapping: {[key: string]: string}) {}
+
+ public sep = '/'
+
+ async exists (filepath: string) {
+ return this.existsSync(filepath)
+ }
+
+ existsSync (filepath: string) {
+ return !isNil(this.mapping[filepath])
+ }
+
+ async readFile (filepath: string) {
+ return this.readFileSync(filepath)
+ }
+
+ readFileSync (filepath: string) {
+ const content = this.mapping[filepath]
+ if (isNil(content)) throw new Error(`ENOENT: ${filepath}`)
+ return content
+ }
+
+ dirname (filepath: string) {
+ const segments = filepath.split(this.sep)
+ segments.pop()
+ return segments.join(this.sep)
+ }
+
+ resolve (dir: string, file: string, ext: string) {
+ file += ext
+ if (dir === '.') return file
+ const segments = dir.split(this.sep)
+ for (const segment of file.split(this.sep)) {
+ if (segment === '.' || segment === '') continue
+ else if (segment === '..') segments.pop()
+ else segments.push(segment)
+ }
+ return segments.join(this.sep)
+ }
+}
diff --git a/src/liquid-options.ts b/src/liquid-options.ts
index ca5340b4d..dc9033224 100644
--- a/src/liquid-options.ts
+++ b/src/liquid-options.ts
@@ -5,6 +5,7 @@ import * as fs from './fs/fs-impl'
import { defaultOperators, Operators } from './render'
import misc from './filters/misc'
import { escape } from './filters/html'
+import { MapFS } from './fs/map-fs'
type OutputEscape = (value: any) => string
type OutputEscapeOption = 'escape' | 'json' | OutputEscape
@@ -64,6 +65,8 @@ export interface LiquidOptions {
greedy?: boolean;
/** `fs` is used to override the default file-system module with a custom implementation. */
fs?: FS;
+ /** Render from in-memory `templates` mapping instead of file system. File system related options like `fs`, 'root', and `relativeReference` will be ignored when `templates` is specified. */
+ templates?: {[key: string]: string};
/** the global scope passed down to all partial and layout templates, i.e. templates included by `include`, `layout` and `render` tags. */
globals?: object;
/** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */
@@ -190,6 +193,11 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
options.partials = normalizeDirectoryList(options.partials)
options.layouts = normalizeDirectoryList(options.layouts)
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
+ if (options.templates) {
+ options.fs = new MapFS(options.templates)
+ options.relativeReference = true
+ options.root = options.partials = options.layouts = '.'
+ }
return options as NormalizedFullOptions
}
diff --git a/test/integration/liquid/fs-option.spec.ts b/test/integration/liquid/fs-option.spec.ts
index eb1696a1c..99fd2be92 100644
--- a/test/integration/liquid/fs-option.spec.ts
+++ b/test/integration/liquid/fs-option.spec.ts
@@ -49,4 +49,34 @@ describe('LiquidOptions#fs', function () {
} as any)
expect(engine.options.relativeReference).toBe(false)
})
+ it('should render from in-memory templates', () => {
+ const engine = new Liquid({
+ templates: {
+ entry: '{% layout "main" %}entry',
+ main: 'header {% block %}{% endblock %} footer'
+ }
+ })
+ expect(engine.renderFileSync('entry')).toEqual('header entry footer')
+ })
+ it('should render relative in-memory templates', () => {
+ const engine = new Liquid({
+ templates: {
+ 'views/entry': 'header {% include "../partials/footer" %}',
+ 'partials/footer': 'footer'
+ }
+ })
+ expect(engine.renderFileSync('views/entry')).toEqual('header footer')
+ })
+ it('should ignore root/layouts/partials', () => {
+ const engine = new Liquid({
+ root: '/foo/bar/',
+ layouts: '/foo/bar/',
+ partials: '/foo/bar/',
+ templates: {
+ entry: '{% layout "main" %}entry',
+ main: 'header {% block %}{% endblock %} footer'
+ }
+ })
+ expect(engine.renderFileSync('entry')).toEqual('header entry footer')
+ })
})