Asynchronous Nunjucks with Filters and Extensions
The Problem
- You want to have a method to download a data as json from a server
{% set dataSource = userSelection %} {% set data = loadData('https://mydomain/sources/' + dataSource + '.json') %}
Nunjucks Filters
Nunjucks Filters are making the language more flexibel. Filters are nothing else then a function. In Nunjucks you can apply a filter with a pipe "|". The value in front of the pipe is passed as the first argument to the Nunjucks filter function. You can also combine multiple filters after each other
{{ "hello" | title }} // => "Hello"
{{ ['apple', 'banana', 'cherry'] | join(",") }} // => "apple,banana,cherry"
{{ "foo" | replace("foo", "bar") | capitalize }} // => "BAR"
How to solve the async problem with Nunjucks Filters
Nunjucks Filter support asynchronous calls. The easiest way is to make an await filter which gets a Promise as an argument. So imagine loadData('https://...') returns a Promise. Now you can do this.
We rename loadData to loadDataAsync so it fits to the most languange syntax
{{ loadDataAsync('https://...') | await }}
nunjucksEnvironment.addFilter(
'await',
(promise, callback) => promise.then(result => callback(null, result)),
true,
);
nunjucksEnvironment.renderString(
template,
nunjucksContext,
(error, result) => {
console.log(result);
}
);
Nunjucks Extensions
Nunjucks Extensions are Nujucks Filters on stereoids. You can really define your own syntax here.
The problem here is that you will not find any documentation on how to implement a nunjucks extension. They advise was to read the nunjucks code 🥴. The sparse nunjucks documentation for creating custom extensions is here.
An example of a nunjucks extension:
{% sum 5,6 %}
Where "sum" is the "Nunjucks Tag" and 5 and 6 are the arguments. Lets implement this extension to see how it works.
function SumExtension() {
this.tags = ['sum'];
this.parse = function (parser, nodes) {
// The first node is the sum node.
// With nextToken we "jump over" sum node to 5,6
const sumNode = parser.nextToken();
// Nujucks parser offers a methode parseSignature which helps to parse arguments of a tag
// 5 and 6 are now parsed as argumen nodes.
const argNodes = parser.parseSignature(null, true);
// We say to the parser that he can move to the end of the line to parse next things.
parser.advanceAfterBlockEnd(sumNode.value);
// The CallExtension method on the nodes is a helper which compiles
// the argument nodes. The second argument is the name of the method to
// pass the compiled code.
return new nodes.CallExtension(this, 'run', argNodes, []);
};
this.run = function (environment, leftOperand, rightOperand) {
// We now return a safe string with the sum of the operation.
return new nunjucks.runtime.SafeString(`${leftOperand + rightOperand}`);
};
}
const nunjucksEnvironment = nunjucks.configure([], {
autoescape: false,
throwOnUndefined: false,
});
nunjucksEnvironment.addExtension('SumExtenstion', new SumExtension());
const template = '{% sum 5,6 %}';
const result = nunjucksEnvironment.renderString(template, {});
Lets make another Extension which has a body component:
function UppercaseExtension() {
this.tags = ['uppercase'];
this.parse = function (parser, nodes) {
// The first node is the uppercase node.
// With nextToken we "jump over" uppercase to the blockend node
parser.nextToken();
// We skip over the %} end-block token
// If we encounter another symbol then an end-block we fail.
if (!parser.skip(lexer.TOKEN_BLOCK_END)){
parser.fail('uppercase: end of block expected');
return;
};
// parse everything between uppercase and enduppercase blocks
const bodyNode = parser.parseUntilBlocks('enduppercase');
// move forward after the %} endblock. We are finished.
parser.advanceAfterBlockEnd();
// The CallExtension method is a helper which compiles
// the nodes we pass. The second argument is the name of the method to
// pass the compiled code.
return new nodes.CallExtension(this, 'run', undefined, [bodyNode]);
};
this.run = function (environment, body) {
// body is a function and can return the compiled value
const str = body();
// We now return a safe string with the sum of the operation.
return new nunjucks.runtime.SafeString(str.toUpperCase());
};
}
const nunjucksEnvironment = nunjucks.configure([], {
autoescape: false,
throwOnUndefined: false,
});
nunjucksEnvironment.addExtension('UppercaseExtension', new UppercaseExtension());
const template = `
{% set name = "Baris" %}
{% uppercase %}
hello {{name}}
{% enduppercase %}`;
const result = nunjucksEnvironment.renderString(template, {});
function ConstExtension() {
this.tags = ['const'];
this.parse = function (parser, nodes) {
// The first node is the const node.
// With nextToken we "jump over" the const node
const token = parser.nextToken();
// parse the signature var = value
const args = parser.parseSignature(false, true);
// move after the %} endblock. We are finished
parser.advanceAfterBlockEnd(token.value);
// The CallExtension method on the nodes is a helper which compiles
// the body nodes. The second argument is the name of the method to
// pass the compiled code.
return new nodes.CallExtension(this, 'run', args);
};
this.run = function (environment, args) {
// args is an object with key-value pair
//e.g. { name: "Baris" }
const varName = Object.keys(args)[0]; // "name"
const varValue = args[varName]; // "Baris"
const constVars = environment.ctx['@CONST_VARS'] || [];
// check if we already added the variable name to our array
// that hold the const vars
if (constVars.includes(varName)) {
throw Error(`${varName} already declared`);
}
// Add variable name to the array so that we know
// next time it was used.
constVars.length
? environment.ctx['@CONST_VARS'].push(varName)
: (environment.ctx['@CONST_VARS'] = [varName]);
// set the nunjucks context so
// name is now "Baris"
environment.ctx[varName] = varValue;
};
}
const nunjucksEnvironment = nunjucks.configure([], {
autoescape: false,
throwOnUndefined: false,
});
nunjucksEnvironment.addExtension('ConstExtension', new ConstExtension());
const template = `
{% set surname = "Bikmaz" %}
{% const name = "Baris " + surname %}
hello {{name}}
{% const name = "Elvis" %} // ===> This line should throw an error.
hello {{name}} `;
const result = nunjucksEnvironment.renderString(template, {});
Asynchronous Extensions
export function DelayExtension() {
this.tags = ['delay'];
this.parse = function (parser, nodes) {
// The first node is the delay node.
// With nextToken we "jump over" over it to the next node
const delayNode = parser.nextToken();
// Nujucks parser offers a methode parseSignature which helps to parse arguments of a tag
// 2 and the text are now parsed as argument nodes.
const argNodes = parser.parseSignature(null, true);
// Go until block-end "%}" node. We are finished.
parser.advanceAfterBlockEnd(delayNode.value);
// Note that we call CallExtensionAsync now instead of CallExtension.
// This will add a callback argument the "runDelay" method
return new nodes.CallExtensionAsync(this, 'runDelay', argNodes, []);
};
// Note the callback function. We must call it to tell nunjucks
// that the extension has been executed.
this.runDelay = function (_, secs: number, text: string, callback: () => void) {
const milisecs = secs * 1000;
window.setTimeout(() =>{
new nunjucks.runtime.SafeString(text);
callback();
}, milisecs);
};
}
const nunjucksEnvironment = nunjucks.configure([], {
autoescape: false,
throwOnUndefined: false,
});
nunjucksEnvironment.addExtension('DelayExtension', new DelayExtension());
const template = 'Hello {% delay 2, "World" %}';
// We must use now the renderString method with a
// callback as we have implemented an async Extension
nunjucksEnvironment.renderString(template, {}, (error, result)=>{
const appDiv: HTMLElement = document.getElementById('app');
appDiv.innerHTML = result;
});
Parser Methods
-
advanceAfterBlockEnd(name?: string): Node
Moves to the next node and checks if it's a block-end token like "%} else it fails. If you have a closing tag like {% endif %} then you don't need to pass a name. If not you need to pass a name.
-
advanceAfterVariableEnd(): Node
Moves to the next node and checks if it's a variable-end token else it fails.
-
fail(msg: string, lineNr: number, colNr: number)
Throws an error message of the error on the current node.
-
parseExpression(): Node
Parses expressions like r=5 or 2+2-3.
-
parseSignature(tolerant?: boolean, noParanthesis?: boolean): Node
Parses a signature. A signature is a comma seperated expression list like 6, "Hello", 8+1, as="World". The noParanthesis argument tells the parser that the arguments are not within paranthesis. If tolereant is set the method does not throw.
-
parseUntilBlocks(...blockNames: string | string[]): NodeList
If you have a block expression like {% if(..) %} {%else%} {% endif %} and you are at the block-end of the first if as an example you can call parseUntilBlocks('else', 'endif') to parse all the block between.
-
peekToken(): Node
Takes the current node but does not move next node.
-
skip(type: string): boolean
Moves to the next node and returns true if the node is of the given type, e.g. skip(lexer.TOKEN_SYMBOL)
-
skipValue(type: string, val: string): boolean
Moves to the next node and returns true if the node is of the given type and value, e.g. skipValue('operator', '+')
Comments
Post a Comment