Skip to main content

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.

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);
}
}

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.

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);
}

Our section tag is a minimal extension of the include tag.

section_tag.js
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.

info

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.

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;
}
}

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().

mongo_loader.mjs
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);