Custom Loaders
Template loaders are responsible for finding a template's source text given a name or identifier. You might want to write a custom template loader to read templates from a database, narrow the template search space for a specific user, or add extra context data to a template, for example.
Write a custom template loader by extending the Loader base class and implementing its getSource() and getSourceSync() methods. Then configure an Environment to use your loader with the loader option.
Example Map Loader
This example loader reads templates from a Map of template names to template source text strings.
getSource() and getSourceSync() are expected to return a TemplateSource object representing the template source and associated meta data. If a template's source can not be found, getSource() and getSourceSync() should throw a TemplateNotFoundError.
- JavaScript
- TypeScript
import { Loader, TemplateSource, TemplateNotFoundError } from "liquidscript";
export class MapLoader extends Loader {
#map;
constructor(map) {
super();
this.#map = map === undefined ? new Map() : map;
}
async getSource(name) {
return this.getSourceSync(name);
}
getSourceSync(name) {
const source = this.#map.get(name);
if (source === undefined) throw new TemplateNotFoundError(name);
return new TemplateSource(source, name);
}
}
import { Loader, TemplateSource, TemplateNotFoundError } from "liquidscript";
class MapLoader extends Loader {
#templateMap: Map<string, string>;
constructor(map?: Map<string, string>) {
super();
this.#templateMap = map === undefined ? new Map<string, string>() : map;
}
public async getSource(name: string): Promise<TemplateSource> {
return this.getSourceSync(name);
}
public getSourceSync(name: string): TemplateSource {
const source = this.#templateMap.get(name);
if (source === undefined) throw new TemplateNotFoundError(name);
return new TemplateSource(source, name);
}
}
If MapLoader is exported from a "my_loaders" module, we can import it and configure an Environment to use it like this.
import { Environment } from "liquidscript";
import { MapLoader } from "./my_loaders";
const templates = new Map([
["some_template", "{% include 'some_snippet' %}"],
["some_snippet", "Hello, {{ you }}!"],
]);
const env = new Environment({ loader: new MapLoader(templates) });
const template = env.getTemplateSync("some_template");
console.log(template.renderSync({ you: "World" }));
// Hello, World!
Loading Sections and Snippets
We can mimic Shopify's snippet and static section loading behavior with a custom template loader and section tag. This example will look for templates rendered with {% include %} or {% render %} in a snippets subfolder, those rendered with {% section %} in a section subfolder, and all other templates in a templates subfolder.
SectionLoader makes use of the LoaderContext object passed to getSource() and getSourceSync(). LoaderContext can contain any arbitrary objects and primitives that a template loader might use to modify its search space or retrieve extra template meta data. By convention, the built in include and render tags add a tag property to their LoaderContext, allowing us to determine which tag, if any, is trying to load a template.
- JavaScript
- TypeScript
import fsCallback from "fs";
import fs from "fs/promises";
import path from "path";
import {
Loader,
TemplateSource,
TemplateNotFoundError,
object,
tags,
} from "liquidscript";
class SectionLoader extends Loader {
#path;
#sections;
#snippets;
#templates;
encoding = "utf8";
fileExtension = ".liquid";
constructor(searchPath) {
super();
this.#path = searchPath;
this.#sections = path.join(this.#path, "sections");
this.#snippets = path.join(this.#path, "snippets");
this.#templates = path.join(this.#path, "templates");
}
async getSource(name, renderContext, loaderContext) {
const templatePath = await this.resolve(
this.withFileExtension(name),
object.liquidStringify(
loaderContext === undefined ? undefined : loaderContext.tag
)
);
const source = await fs.readFile(templatePath, { encoding: this.encoding });
return new TemplateSource(source, templatePath);
}
getSourceSync(name, renderContext, loaderContext) {
const templatePath = this.resolveSync(
this.withFileExtension(name),
object.liquidStringify(
loaderContext === undefined ? undefined : loaderContext.tag
)
);
const source = fsCallback.readFileSync(templatePath, {
encoding: this.encoding,
});
return new TemplateSource(source, templatePath);
}
withFileExtension(name) {
return path.extname(name) ? name : name + this.fileExtension;
}
async resolve(name, tag) {
const searchPath = this.resolveTag(tag);
const templatePath = path.join(searchPath, path.normalize(name));
if (!isSubPath(searchPath, templatePath))
throw new TemplateNotFoundError(name);
try {
const stat = await fs.stat(templatePath);
if (stat.isFile()) return templatePath;
throw new TemplateNotFoundError(name);
} catch {
throw new TemplateNotFoundError(name);
}
}
resolveSync(name, tag) {
const searchPath = this.resolveTag(tag);
const templatePath = path.join(searchPath, path.normalize(name));
if (!isSubPath(searchPath, templatePath))
throw new TemplateNotFoundError(name);
try {
const stat = fsCallback.statSync(templatePath);
if (stat.isFile()) return templatePath;
throw new TemplateNotFoundError(name);
} catch {
throw new TemplateNotFoundError(name);
}
}
resolveTag(tag) {
switch (tag) {
case "render":
case "include":
return this.#snippets;
case "section":
return this.#sections;
case "":
return this.#templates;
default:
throw new TemplateNotFoundError(
"SectionLoader can only load 'render', 'include' and 'section' tags"
);
}
}
}
function isSubPath(parent, dir) {
const relative = path.relative(parent, dir);
return !!relative && !relative.startsWith(".") && !path.isAbsolute(relative);
}
import fsCallback from "fs";
import fs from "fs/promises";
import path from "path";
import {
Loader,
RenderContext,
TemplateSource,
TemplateNotFoundError,
object,
tags,
} from "liquidscript";
class SectionLoader extends Loader {
#path: string;
#sections: string;
#snippets: string;
#templates: string;
readonly encoding: BufferEncoding = "utf8";
readonly fileExtension: string = ".liquid";
constructor(searchPath: string) {
super();
this.#path = searchPath;
this.#sections = path.join(this.#path, "sections");
this.#snippets = path.join(this.#path, "snippets");
this.#templates = path.join(this.#path, "templates");
}
public async getSource(
name: string,
renderContext?: RenderContext,
loaderContext?: { [index: string]: unknown }
): Promise<TemplateSource> {
const templatePath = await this.resolve(
this.withFileExtension(name),
object.liquidStringify(loaderContext?.tag)
);
const source = await fs.readFile(templatePath, { encoding: this.encoding });
return new TemplateSource(source, templatePath);
}
public getSourceSync(
name: string,
renderContext?: RenderContext,
loaderContext?: { [index: string]: unknown }
): TemplateSource {
const templatePath = this.resolveSync(
this.withFileExtension(name),
object.liquidStringify(loaderContext?.tag)
);
const source = fsCallback.readFileSync(templatePath, {
encoding: this.encoding,
});
return new TemplateSource(source, templatePath);
}
protected withFileExtension(name: string): string {
return path.extname(name) ? name : name + this.fileExtension;
}
protected async resolve(name: string, tag: string): Promise<string> {
const searchPath = this.resolveTag(tag);
const templatePath = path.join(searchPath, path.normalize(name));
if (!isSubPath(searchPath, templatePath))
throw new TemplateNotFoundError(name);
try {
const stat = await fs.stat(templatePath);
if (stat.isFile()) return templatePath;
throw new TemplateNotFoundError(name);
} catch {
throw new TemplateNotFoundError(name);
}
}
protected resolveSync(name: string, tag: string): string {
const searchPath = this.resolveTag(tag);
const templatePath = path.join(searchPath, path.normalize(name));
if (!isSubPath(searchPath, templatePath))
throw new TemplateNotFoundError(name);
try {
const stat = fsCallback.statSync(templatePath);
if (stat.isFile()) return templatePath;
throw new TemplateNotFoundError(name);
} catch {
throw new TemplateNotFoundError(name);
}
}
protected resolveTag(tag: string): string {
switch (tag) {
case "render":
case "include":
return this.#snippets;
case "section":
return this.#sections;
case "":
return this.#templates;
default:
throw new TemplateNotFoundError(
"SectionLoader can only load 'render', 'include' and 'section' tags"
);
}
}
}
function isSubPath(parent: string, dir: string): boolean {
const relative = path.relative(parent, dir);
return !!relative && !relative.startsWith(".") && !path.isAbsolute(relative);
}
Our section tag is a minimal extension of the include tag.
class SectionTag extends tags.IncludeTag {
name = "section";
nodeClass = SectionNode;
}
class SectionNode extends tags.IncludeNode {
tag = "section";
}
With SectionLoader exported from a "section_loader" module and SectionTag exported from "section_tag", we can configure an Environment like this.
import { Environment, StrictUndefined } from "liquidscript";
import { SectionLoader } from "./section_loader";
import { SectionTag } from "./section_tag";
const env = new Environment({
loader: new SectionLoader("templates/"),
undefinedFactory: StrictUndefined.from,
});
env.addTag("section", new SectionTag());
Caching Loaders
Parsing a Liquid template is significantly slower than rendering a Liquid template (not including render-time IO). As such, we should cache parsed templates where possible to prevent the same template being parsed multiple times unnecessarily. One example scenario where even a modest in-memory cache can yield a noticeable performance improvement is that of including a partial template repeatedly inside a for loop.
Please see the implementation of CachingNodeFileSystemLoader in src/builtin/loaders/file_system_loader.ts for a full example.
CachingNodeFileSystemLoader overrides load() and loadSync() of its parent Loader class. It uses an LRUCache, which is checked before delegating to getSource() and getSourceSync() in the event of a cache miss.
The optional upToDate and upToDateSync properties of a TemplateSource provide a way to bust a template cache if the underlying source text has been modified.
Front Matter Loader
A TemplateSource object, as returned by getSource() and getSourceSync() of a template Loader, optionally includes a matter property. If given, matter should be extra render context data in addition to environment globals and template globals. Like template globals, matter data is pinned to a template and will be merged with environment globals at render time.
This example implements a front matter template loader by extending NodeFileSystemLoader and parsing YAML from the start of a every template source file.
- JavaScript
- TypeScript
import {
Environment,
NodeFileSystemLoader,
TemplateSource,
} from "liquidscript";
import yaml from "js-yaml";
const RE_FRONT_MATTER = /^\s*---\s*(.*?)\s*---\s*/ms;
class FrontMatterLoader extends NodeFileSystemLoader {
async getSource(name) {
return this.loadMatter(await super.getSource(name));
}
getSourceSync(name) {
return this.loadMatter(super.getSourceSync(name));
}
loadMatter(templateSource) {
const match = templateSource.source.match(RE_FRONT_MATTER);
if (match) {
// TODO: check YAML schema and handle YAML errors
const matter = yaml.load(match[1]);
return new TemplateSource(
templateSource.source.slice(match[0].length),
templateSource.name,
matter,
templateSource.upToDate,
templateSource.upToDateSync
);
}
return templateSource;
}
}
import {
ContextScope,
NodeFileSystemLoader,
object,
TemplateSource,
} from "liquidscript";
import yaml from "js-yaml";
const RE_FRONT_MATTER = /^\s*---\s*(.*?)\s*---\s*/ms;
class FrontMatterLoader extends NodeFileSystemLoader {
public async getSource(name: string): Promise<TemplateSource> {
return this.loadMatter(await super.getSource(name));
}
public getSourceSync(name: string): TemplateSource {
return this.loadMatter(super.getSourceSync(name));
}
protected loadMatter(templateSource: TemplateSource): TemplateSource {
const match = templateSource.source.match(RE_FRONT_MATTER);
if (match) {
// TODO: check YAML schema and handle YAML errors
const matter = yaml.load(match[1]);
if (object.isContextScope(matter)) {
return new TemplateSource(
templateSource.source.slice(match[0].length),
templateSource.name,
matter,
templateSource.upToDate,
templateSource.upToDateSync
);
}
}
return templateSource;
}
}
Scoped Database Loader
We can implement a scoped template loader using the loaderContext argument to getTemplate() or as part of the templateContext argument to fromString(). A scoped template loader is useful for multi-user application where each user has their own collection of templates.
This example loader reads templates from a MongoDB database, narrowing its search space using a uid property set on the loaderContext object passed to getSource().
import { Loader, TemplateNotFoundError, TemplateSource } from "liquidscript";
export class MongoDBLoader extends Loader {
constructor(collection) {
super();
this.collection = collection;
this.re = /[a-zA-Z][a-zA-Z0-9_\-]+/;
}
async getSource(name, renderContext, loaderContext) {
const uid = loaderContext !== undefined ? loaderContext.uid : undefined;
if (uid === undefined)
throw new TemplateNotFoundError(
"MongoDBLoader requires a loader context with a 'uid' property"
);
if (!this.re.test(name))
throw new TemplateNotFoundError(`invalid template name: ${name}`);
const query = { uid: uid, [`templates.${name}`]: { $exists: 1 } };
const projection = { _id: 0, [`templates.${name}`]: 1 };
const result = await this.collection.findOne(query, projection);
if (!result) throw new TemplateNotFoundError(name);
return new TemplateSource(result.templates[name], name);
}
getSourceSync() {
throw new Error("MongoDBLoader is an async only loader");
}
}
The MongoDBLoader constructor expects a MongoDB Collection from an already connected client, and that documents in that collection have a uid and templates field. Fields in the embedded templates document are Liquid template names and their values are Liquid template source text strings.
If MongoDBLoader is exported from the module "mongo_loader.mjs", we could use it like this.
import { Environment } from "liquidscript";
import { MongoClient } from "mongodb";
import { MongoDBLoader } from "./mongo_loader";
const uri = "mongodb://<somehost>";
const client = new MongoClient(uri);
async function run() {
try {
await client.connect();
const db = client.db("mydatabase");
const col = db.collection("mycollection");
const env = new Environment({ loader: new MongoDBLoader(col) });
const template = await env.getTemplate("index", undefined, undefined, {
uid: "ABC0123",
});
const result = await template.render();
console.log(result);
} finally {
await client.close();
}
}
run().catch(console.dir);