From df27ac694739496982012432077fe28b1476662a Mon Sep 17 00:00:00 2001 From: Harttle Date: Mon, 8 Jul 2024 02:25:25 +0800 Subject: [PATCH] feat: support in-memory template mapping, inspired by @jg-rp #714 --- docs/source/tutorials/render-file.md | 17 +++++++++ src/fs/map-fs.spec.ts | 17 +++++++++ src/fs/map-fs.ts | 43 +++++++++++++++++++++++ src/liquid-options.ts | 8 +++++ test/integration/liquid/fs-option.spec.ts | 30 ++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 src/fs/map-fs.spec.ts create mode 100644 src/fs/map-fs.ts diff --git a/docs/source/tutorials/render-file.md b/docs/source/tutorials/render-file.md index f7885607bb..4ee1e63d8c 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 0000000000..269318d26b --- /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 0000000000..8783b14cce --- /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 ca5340b4dd..dc90332243 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 eb1696a1c6..99fd2be92f 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') + }) })