332 lines
8.6 KiB
JavaScript
332 lines
8.6 KiB
JavaScript
|
|
import moo from "moo";
|
|||
|
|
import { Tokenizer, TokenKind, evalToken, Liquid as LiquidJs } from "liquidjs";
|
|||
|
|
import { TemplatePath } from "@11ty/eleventy-utils";
|
|||
|
|
// import debugUtil from "debug";
|
|||
|
|
|
|||
|
|
import TemplateEngine from "./TemplateEngine.js";
|
|||
|
|
import { augmentObject } from "./Util/ContextAugmenter.js";
|
|||
|
|
|
|||
|
|
// const debug = debugUtil("Eleventy:Liquid");
|
|||
|
|
|
|||
|
|
export default class Liquid extends TemplateEngine {
|
|||
|
|
static argumentLexerOptions = {
|
|||
|
|
number: /[0-9]+\.*[0-9]*/,
|
|||
|
|
doubleQuoteString: /"(?:\\["\\]|[^\n"\\])*"/,
|
|||
|
|
singleQuoteString: /'(?:\\['\\]|[^\n'\\])*'/,
|
|||
|
|
keyword: /[a-zA-Z0-9.\-_]+/,
|
|||
|
|
"ignore:whitespace": /[, \t]+/, // includes comma separator
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
constructor(name, eleventyConfig) {
|
|||
|
|
super(name, eleventyConfig);
|
|||
|
|
|
|||
|
|
this.liquidOptions = this.config.liquidOptions || {};
|
|||
|
|
|
|||
|
|
this.setLibrary(this.config.libraryOverrides.liquid);
|
|||
|
|
|
|||
|
|
this.argLexer = moo.compile(Liquid.argumentLexerOptions);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get cacheable() {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLibrary(override) {
|
|||
|
|
// warning, the include syntax supported here does not exactly match what Jekyll uses.
|
|||
|
|
this.liquidLib = override || new LiquidJs(this.getLiquidOptions());
|
|||
|
|
this.setEngineLib(this.liquidLib, Boolean(this.config.libraryOverrides.liquid));
|
|||
|
|
|
|||
|
|
this.addFilters(this.config.liquidFilters);
|
|||
|
|
|
|||
|
|
// TODO these all go to the same place (addTag), add warnings for overwrites
|
|||
|
|
this.addCustomTags(this.config.liquidTags);
|
|||
|
|
this.addAllShortcodes(this.config.liquidShortcodes);
|
|||
|
|
this.addAllPairedShortcodes(this.config.liquidPairedShortcodes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getLiquidOptions() {
|
|||
|
|
let defaults = {
|
|||
|
|
root: [this.dirs.includes, this.dirs.input], // supplemented in compile with inputPath below
|
|||
|
|
extname: ".liquid",
|
|||
|
|
strictFilters: true,
|
|||
|
|
// TODO?
|
|||
|
|
// cache: true,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let options = Object.assign(defaults, this.liquidOptions || {});
|
|||
|
|
// debug("Liquid constructor options: %o", options);
|
|||
|
|
|
|||
|
|
return options;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static wrapFilter(name, fn) {
|
|||
|
|
/**
|
|||
|
|
* @this {object}
|
|||
|
|
*/
|
|||
|
|
return function (...args) {
|
|||
|
|
// Set this.eleventy and this.page
|
|||
|
|
if (typeof this.context?.get === "function") {
|
|||
|
|
augmentObject(this, {
|
|||
|
|
source: this.context,
|
|||
|
|
getter: (key, context) => context.get([key]),
|
|||
|
|
|
|||
|
|
lazy: this.context.strictVariables,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// We *don’t* wrap this in an EleventyFilterError because Liquid has a better error message with line/column information in the template
|
|||
|
|
return fn.call(this, ...args);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Shortcodes
|
|||
|
|
static normalizeScope(context) {
|
|||
|
|
let obj = {};
|
|||
|
|
if (context) {
|
|||
|
|
obj.ctx = context; // Full context available on `ctx`
|
|||
|
|
|
|||
|
|
// Set this.eleventy and this.page
|
|||
|
|
augmentObject(obj, {
|
|||
|
|
source: context,
|
|||
|
|
getter: (key, context) => context.get([key]),
|
|||
|
|
lazy: context.strictVariables,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return obj;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addCustomTags(tags) {
|
|||
|
|
for (let name in tags) {
|
|||
|
|
this.addTag(name, tags[name]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addFilters(filters) {
|
|||
|
|
for (let name in filters) {
|
|||
|
|
this.addFilter(name, filters[name]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addFilter(name, filter) {
|
|||
|
|
this.liquidLib.registerFilter(name, Liquid.wrapFilter(name, filter));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addTag(name, tagFn) {
|
|||
|
|
let tagObj;
|
|||
|
|
if (typeof tagFn === "function") {
|
|||
|
|
tagObj = tagFn(this.liquidLib);
|
|||
|
|
} else {
|
|||
|
|
throw new Error(
|
|||
|
|
"Liquid.addTag expects a callback function to be passed in: addTag(name, function(liquidEngine) { return { parse: …, render: … } })",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
this.liquidLib.registerTag(name, tagObj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addAllShortcodes(shortcodes) {
|
|||
|
|
for (let name in shortcodes) {
|
|||
|
|
this.addShortcode(name, shortcodes[name]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addAllPairedShortcodes(shortcodes) {
|
|||
|
|
for (let name in shortcodes) {
|
|||
|
|
this.addPairedShortcode(name, shortcodes[name]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseArguments(lexer, str) {
|
|||
|
|
let argArray = [];
|
|||
|
|
|
|||
|
|
if (!lexer) {
|
|||
|
|
lexer = moo.compile(Liquid.argumentLexerOptions);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof str === "string") {
|
|||
|
|
lexer.reset(str);
|
|||
|
|
|
|||
|
|
let arg = lexer.next();
|
|||
|
|
while (arg) {
|
|||
|
|
/*{
|
|||
|
|
type: 'doubleQuoteString',
|
|||
|
|
value: '"test 2"',
|
|||
|
|
text: '"test 2"',
|
|||
|
|
toString: [Function: tokenToString],
|
|||
|
|
offset: 0,
|
|||
|
|
lineBreaks: 0,
|
|||
|
|
line: 1,
|
|||
|
|
col: 1 }*/
|
|||
|
|
if (arg.type.indexOf("ignore:") === -1) {
|
|||
|
|
// Push the promise into an array instead of awaiting it here.
|
|||
|
|
// This forces the promises to run in order with the correct scope value for each arg.
|
|||
|
|
// Otherwise they run out of order and can lead to undefined values for arguments in layout template shortcodes.
|
|||
|
|
// console.log( arg.value, scope, engine );
|
|||
|
|
argArray.push(arg.value);
|
|||
|
|
}
|
|||
|
|
arg = lexer.next();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return argArray;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static parseArgumentsBuiltin(args) {
|
|||
|
|
let tokenizer = new Tokenizer(args);
|
|||
|
|
let parsedArgs = [];
|
|||
|
|
|
|||
|
|
let value = tokenizer.readValue();
|
|||
|
|
while (value) {
|
|||
|
|
parsedArgs.push(value);
|
|||
|
|
tokenizer.skipBlank();
|
|||
|
|
if (tokenizer.peek() === ",") {
|
|||
|
|
tokenizer.advance();
|
|||
|
|
}
|
|||
|
|
value = tokenizer.readValue();
|
|||
|
|
}
|
|||
|
|
tokenizer.end();
|
|||
|
|
|
|||
|
|
return parsedArgs;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addShortcode(shortcodeName, shortcodeFn) {
|
|||
|
|
let _t = this;
|
|||
|
|
this.addTag(shortcodeName, function (liquidEngine) {
|
|||
|
|
return {
|
|||
|
|
parse(tagToken) {
|
|||
|
|
this.name = tagToken.name;
|
|||
|
|
if (_t.config.liquidParameterParsing === "builtin") {
|
|||
|
|
this.orderedArgs = Liquid.parseArgumentsBuiltin(tagToken.args);
|
|||
|
|
// note that Liquid does have a Hash class for name-based argument parsing but offers no easy to support both modes in one class
|
|||
|
|
} else {
|
|||
|
|
this.legacyArgs = tagToken.args;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
render: function* (ctx) {
|
|||
|
|
let argArray = [];
|
|||
|
|
|
|||
|
|
if (this.legacyArgs) {
|
|||
|
|
let rawArgs = Liquid.parseArguments(_t.argLexer, this.legacyArgs);
|
|||
|
|
for (let arg of rawArgs) {
|
|||
|
|
let b = yield liquidEngine.evalValue(arg, ctx);
|
|||
|
|
argArray.push(b);
|
|||
|
|
}
|
|||
|
|
} else if (this.orderedArgs) {
|
|||
|
|
for (let arg of this.orderedArgs) {
|
|||
|
|
let b = yield evalToken(arg, ctx);
|
|||
|
|
argArray.push(b);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let ret = yield shortcodeFn.call(Liquid.normalizeScope(ctx), ...argArray);
|
|||
|
|
return ret;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addPairedShortcode(shortcodeName, shortcodeFn) {
|
|||
|
|
let _t = this;
|
|||
|
|
this.addTag(shortcodeName, function (liquidEngine) {
|
|||
|
|
return {
|
|||
|
|
parse(tagToken, remainTokens) {
|
|||
|
|
this.name = tagToken.name;
|
|||
|
|
|
|||
|
|
if (_t.config.liquidParameterParsing === "builtin") {
|
|||
|
|
this.orderedArgs = Liquid.parseArgumentsBuiltin(tagToken.args);
|
|||
|
|
// note that Liquid does have a Hash class for name-based argument parsing but offers no easy to support both modes in one class
|
|||
|
|
} else {
|
|||
|
|
this.legacyArgs = tagToken.args;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.templates = [];
|
|||
|
|
|
|||
|
|
var stream = liquidEngine.parser
|
|||
|
|
.parseStream(remainTokens)
|
|||
|
|
.on("template", (tpl) => this.templates.push(tpl))
|
|||
|
|
.on("tag:end" + shortcodeName, () => stream.stop())
|
|||
|
|
.on("end", () => {
|
|||
|
|
throw new Error(`tag ${tagToken.raw} not closed`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
stream.start();
|
|||
|
|
},
|
|||
|
|
render: function* (ctx /*, emitter*/) {
|
|||
|
|
let argArray = [];
|
|||
|
|
if (this.legacyArgs) {
|
|||
|
|
let rawArgs = Liquid.parseArguments(_t.argLexer, this.legacyArgs);
|
|||
|
|
for (let arg of rawArgs) {
|
|||
|
|
let b = yield liquidEngine.evalValue(arg, ctx);
|
|||
|
|
argArray.push(b);
|
|||
|
|
}
|
|||
|
|
} else if (this.orderedArgs) {
|
|||
|
|
for (let arg of this.orderedArgs) {
|
|||
|
|
let b = yield evalToken(arg, ctx);
|
|||
|
|
argArray.push(b);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const html = yield liquidEngine.renderer.renderTemplates(this.templates, ctx);
|
|||
|
|
|
|||
|
|
let ret = yield shortcodeFn.call(Liquid.normalizeScope(ctx), html, ...argArray);
|
|||
|
|
|
|||
|
|
return ret;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parseForSymbols(str) {
|
|||
|
|
if (!str) {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let tokenizer = new Tokenizer(str);
|
|||
|
|
/** @type {Array} */
|
|||
|
|
let tokens = tokenizer.readTopLevelTokens();
|
|||
|
|
let symbols = tokens
|
|||
|
|
.filter((token) => token.kind === TokenKind.Output)
|
|||
|
|
.map((token) => {
|
|||
|
|
// manually remove filters 😅
|
|||
|
|
return token.content.split("|").map((entry) => entry.trim())[0];
|
|||
|
|
});
|
|||
|
|
return symbols;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Don’t return a boolean if permalink is a function (see TemplateContent->renderPermalink)
|
|||
|
|
/** @returns {boolean|undefined} */
|
|||
|
|
permalinkNeedsCompilation(str) {
|
|||
|
|
if (typeof str === "string") {
|
|||
|
|
return this.needsCompilation(str);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
needsCompilation(str) {
|
|||
|
|
let options = this.liquidLib.options;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
str.indexOf(options.tagDelimiterLeft) !== -1 ||
|
|||
|
|
str.indexOf(options.outputDelimiterLeft) !== -1
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async compile(str, inputPath) {
|
|||
|
|
let engine = this.liquidLib;
|
|||
|
|
let tmplReady = engine.parse(str, inputPath);
|
|||
|
|
|
|||
|
|
// Required for relative includes
|
|||
|
|
let options = {};
|
|||
|
|
if (!inputPath || inputPath === "liquid" || inputPath === "md") {
|
|||
|
|
// do nothing
|
|||
|
|
} else {
|
|||
|
|
options.root = [TemplatePath.getDirFromFilePath(inputPath)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return async function (data) {
|
|||
|
|
let tmpl = await tmplReady;
|
|||
|
|
|
|||
|
|
return engine.render(tmpl, data, options);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|