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