Skip to content

Commit df27ac6

Browse files
committed
feat: support in-memory template mapping, inspired by @jg-rp #714
1 parent 834328b commit df27ac6

File tree

5 files changed

+115
-0
lines changed

5 files changed

+115
-0
lines changed

docs/source/tutorials/render-file.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ var engine = new Liquid({
100100

101101
{% note warn Path Traversal Vulnerability %}The default value of <code>contains()</code> always returns true. That means when specifying an abstract file system, you'll need to provide a proper <code>contains()</code> to avoid expose such vulnerabilities.{% endnote %}
102102

103+
## In-memory Template
104+
105+
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.
106+
107+
```typescript
108+
const engine = new Liquid({
109+
templates: {
110+
'views/entry': 'header {% include "../partials/footer" %}',
111+
'partials/footer': 'footer'
112+
}
113+
})
114+
engine.renderFileSync('views/entry'))
115+
// Result: 'header footer'
116+
```
117+
118+
Note that file system options like `root`, `layouts`, `partials`, `relativeReference` will be ignored when `templates` is specified.
119+
103120
[fs]: /api/interfaces/LiquidOptions.html#fs
104121
[ifs]: /api/interfaces/FS.html
105122
[fs-node]: https://github.com/harttle/liquidjs/blob/master/src/fs/fs-impl.ts

src/fs/map-fs.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { MapFS } from './map-fs'
2+
3+
describe('MapFS', () => {
4+
const fs = new MapFS({})
5+
it('should resolve relative file paths', () => {
6+
expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
7+
})
8+
it('should resolve to parent', () => {
9+
expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
10+
})
11+
it('should resolve to root', () => {
12+
expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
13+
})
14+
it('should resolve exceeding root', () => {
15+
expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
16+
})
17+
})

src/fs/map-fs.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { isNil } from '../util'
2+
3+
export class MapFS {
4+
constructor (private mapping: {[key: string]: string}) {}
5+
6+
public sep = '/'
7+
8+
async exists (filepath: string) {
9+
return this.existsSync(filepath)
10+
}
11+
12+
existsSync (filepath: string) {
13+
return !isNil(this.mapping[filepath])
14+
}
15+
16+
async readFile (filepath: string) {
17+
return this.readFileSync(filepath)
18+
}
19+
20+
readFileSync (filepath: string) {
21+
const content = this.mapping[filepath]
22+
if (isNil(content)) throw new Error(`ENOENT: ${filepath}`)
23+
return content
24+
}
25+
26+
dirname (filepath: string) {
27+
const segments = filepath.split(this.sep)
28+
segments.pop()
29+
return segments.join(this.sep)
30+
}
31+
32+
resolve (dir: string, file: string, ext: string) {
33+
file += ext
34+
if (dir === '.') return file
35+
const segments = dir.split(this.sep)
36+
for (const segment of file.split(this.sep)) {
37+
if (segment === '.' || segment === '') continue
38+
else if (segment === '..') segments.pop()
39+
else segments.push(segment)
40+
}
41+
return segments.join(this.sep)
42+
}
43+
}

src/liquid-options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as fs from './fs/fs-impl'
55
import { defaultOperators, Operators } from './render'
66
import misc from './filters/misc'
77
import { escape } from './filters/html'
8+
import { MapFS } from './fs/map-fs'
89

910
type OutputEscape = (value: any) => string
1011
type OutputEscapeOption = 'escape' | 'json' | OutputEscape
@@ -64,6 +65,8 @@ export interface LiquidOptions {
6465
greedy?: boolean;
6566
/** `fs` is used to override the default file-system module with a custom implementation. */
6667
fs?: FS;
68+
/** 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. */
69+
templates?: {[key: string]: string};
6770
/** the global scope passed down to all partial and layout templates, i.e. templates included by `include`, `layout` and `render` tags. */
6871
globals?: object;
6972
/** 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 {
190193
options.partials = normalizeDirectoryList(options.partials)
191194
options.layouts = normalizeDirectoryList(options.layouts)
192195
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
196+
if (options.templates) {
197+
options.fs = new MapFS(options.templates)
198+
options.relativeReference = true
199+
options.root = options.partials = options.layouts = '.'
200+
}
193201
return options as NormalizedFullOptions
194202
}
195203

test/integration/liquid/fs-option.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,34 @@ describe('LiquidOptions#fs', function () {
4949
} as any)
5050
expect(engine.options.relativeReference).toBe(false)
5151
})
52+
it('should render from in-memory templates', () => {
53+
const engine = new Liquid({
54+
templates: {
55+
entry: '{% layout "main" %}entry',
56+
main: 'header {% block %}{% endblock %} footer'
57+
}
58+
})
59+
expect(engine.renderFileSync('entry')).toEqual('header entry footer')
60+
})
61+
it('should render relative in-memory templates', () => {
62+
const engine = new Liquid({
63+
templates: {
64+
'views/entry': 'header {% include "../partials/footer" %}',
65+
'partials/footer': 'footer'
66+
}
67+
})
68+
expect(engine.renderFileSync('views/entry')).toEqual('header footer')
69+
})
70+
it('should ignore root/layouts/partials', () => {
71+
const engine = new Liquid({
72+
root: '/foo/bar/',
73+
layouts: '/foo/bar/',
74+
partials: '/foo/bar/',
75+
templates: {
76+
entry: '{% layout "main" %}entry',
77+
main: 'header {% block %}{% endblock %} footer'
78+
}
79+
})
80+
expect(engine.renderFileSync('entry')).toEqual('header entry footer')
81+
})
5282
})

0 commit comments

Comments
 (0)