This document describes the runtime API to manipulate scriban text templating.
Scriban provides a safe runtime, meaning it doesn't expose any .NET objects that haven't been made explicitly available to a Template.
The runtime is composed of two main parts:
- The parsing/compiler infrastructure that is responsible for parsing a text template and build a runtime representation of it (we will call this an
Abstract Syntax Tree
) - The rendering/evaluation infrastructure that is responsible to render a compiled template to a string. We will see also that we can evaluate expressions without rendering.
The scriban runtime was designed to provide an easy, powerful and extensible infrastructure. For example, we are making sure that nothing in the runtime is using a static, so that you can correctly override all the behaviors of the runtime.
- Parsing a template
- Rendering a template
- Overview
- The
TemplateContext
execution model - The
ScriptObject
- Accessing as regular dictionary objects
- Imports a .NET delegate
- Imports functions from a .NET class
- Automatic functions import from
ScriptObject
- Function arguments, optional and
params
- Accessing nested
ScriptObject
- Imports a
ScriptObject
into anotherScriptObject
- Imports a .NET object instance
- Accessing a .NET object
- read-only properties
- The builtin functions
- The stack of
ScriptObject
- Advanced usages
The Scriban.Template
class is a main entry point to easily parse a template and renders it. The action of parsing consist of compiling the template to a faster runtime representation, suitable later for rendering the template.
This class is mostly a user friendly frontend to the underlying classes used to parse a template. See The Lexer and Parser section for advanded usages.
The Template.Parse
method is a convenient method to parse a template from a string and returns the compiled Template:
var inputTemplateAsText = "This is a {{ name }} template";
// Parse the template
var template = Template.Parse(inputTemplateAsText);
// Check for any errors
if (template.HasErrors)
{
foreach(var error in template.Messages)
{
Console.WriteLine(error);
}
return; // or throw...etc.
}
The returned Template
object has the following relevant properties:
ScriptPage Page {get;}
that contains the compiled template to a root Abstract Syntax Tree (AST). From this object you can navigate through all the statements parsed from the template if necessary. See the section about the Abstract Syntax Treebool HasErrors {get;}
to check if the parsed template has any errors. In that case, theScriptPage Page
property isnull
.List<LogMessage> Messages {get;}
contains the list of warning and error messages while parsing the template.
If you are using the Template.Parse
method, it is important to verify HasErrors
is false
, otherwise you will get a null ScriptPage
object from the Template.Page
property.
The parse method can take an additional argument sourceFilePath
used when reporting syntax errors, typically used to associate a template file read from the disk or an editor and you want to report the exact error to the user.
// Parse the template
var template = Template.Parse(File.ReadAllText(filePath), filePath);
Note that the
sourceFilePath
is not used for accessing the disk (it could be a logical path to a zip file, or the name of tab opened in an editor...etc.). It is only a logical name that is used when reporting errors, but also you will see with the include directive and the setup of the Template Loader that this value can be used to perform an include operation in the relative context to the template path being processed.
By default, when parsing a template, the template is expected to have mixed content of text and scriban code blocks enclosed by {{
and }}
. But you can modify the way a template is parsed by passing a LexerOptions
to the Template.Parse
method.
The parsing mode is defined by the LexerOptions.Mode
property which is ScriptMode.Default
by default (i.e. mixed text and code).
But you can also parse a template that contains directly scripting code (without enclosing {{
}}
), in that case, you can use the ScriptMode.ScriptOnly
mode.
For example illustrate how to use the ScriptOnly
mode:
// Create a template in ScriptOnly mode
var lexerOptions = new LexerOptions() { Mode = ScriptMode.ScriptOnly };
// Notice that code is not enclosed by `{{` and `}}`
var template = Template.Parse("y = x + 1; y;", lexerOptions: lexerOptions);
// Renders it with the specified parameter
var result = template.Evaluate(new {x = 10});
// Prints 11
Console.WriteLine(result);
Scriban provides 3 languages through the ScriptLang
enum:
ScriptLang.Default
: which is the default Scriban LanguageScriptLang.Liquid
: which is used to parse the language with liquid syntax.ScriptLang.Scientific
: which is similar to the default, but handles expression slight differently:- Arguments separated by a space will convert to a multiplication:
2 x
will be evaluated as2 * x
- Except if a function is taking one argument, and in that case it resolves to a function call
cos x
resolves tocos(x)
- Otherwise function calls need to use explicit parenthesis
myfunction(1,2,3)
- Arguments separated by a space will convert to a multiplication:
The language is defined by the LexerOptions.Lang
property which.
For example illustrate how to use the the ScriptLang.Scientific
and the ScriptOnly
mode:
// Create a template in ScriptOnly mode
var lexerOptions = new LexerOptions() { Lang = ScriptLang.Scientific, Mode = ScriptMode.ScriptOnly };
// Notice that code is not enclosed by `{{` and `}}`
var template = Template.Parse("y = x + 1; 2y;", lexerOptions: lexerOptions);
// Renders it with the specified parameter
var result = template.Evaluate(new {x = 10});
// Prints 22
Console.WriteLine(result);
Scriban supports a Lexer and Parser that can understand a Liquid template instead, while still translating it to a Scriban Runtime AST.
You can easily parse an existing liquid template using the Template.ParseLiquid
method:
// An Liquid
var inputTemplateAsText = "This is a {{ name }} template";
// Parse the template
var template = Template.ParseLiquid(inputTemplateAsText);
// Renders the template with the variable `name` exposed to the template
var result = template.Render(new { name = "Hello World"});
// Prints the result: "This is a Hello World template"
Console.WriteLine(result);
Also, in terms of runtime, Liquid builtin functions are supported. They are created with the LiquidTemplateContext
which inherits from the TemplateContext
.
In order to render a template, you need pass a context for the variables, objects, functions that will be accessed by the template.
In the following examples, we have a variable name
that is used by the template:
var inputTemplateAsText = "This is a {{ name }} template";
// Parse the template
var template = Template.Parse(inputTemplateAsText);
// Renders the template with the variable `name` exposed to the template
var result = template.Render(new { name = "Hello World"});
// Prints the result: "This is a Hello World template"
Console.WriteLine(result);
As we can see, we are passing an anonymous objects that has this field/property name
and by calling the Render method, the template is executed with this data model context.
While passing an anonymous object is nice for a hello world example, it is not always enough for more advanced data model scenarios.
In this case, you want to use more directly the TemplateContext
(used by the method Template.Render(object)
) and a ScriptObject
which are both at the core of scriban rendering architecture to provide more powerful constructions & hooks of the data model exposed (variables but also functions...etc.).
The TemplateContext
provides:
- an execution context when evaluating a template. The same instance can be used with many different templates, depending on your requirements.
- A stack of
ScriptObject
that provides the actual variables/functions accessible to the template, accessible throughTemplate.PushGlobal(scriptObj)
andTemplate.PopGlobal()
. Why a stack and how to use this stack is described below. - The text output when evaluating a template, which is accessible through the
Template.Output
property as aStringBuilder
but because you can have nested rendering happening, it is possible to useTemplate.PushOutput()
andTemplate.PopOutput()
to redirect temporarily the output to a new output. This functionality is typically used by thecapture
statement. - Caching of templates previously loaded by an
include
directive (seeinclude
andITemplateLoader
section ) - Various possible overrides to allow fine grained extensibility (evaluation of an expression, conversion to a string, enter/exit/step into a loop...etc.)
Note that a TemplateContext
is not thread safe, so it is recommended to have one TemplateContext
per thread.
The ScriptObject
is a special implementation of a Dictionary<string, object>
that runtime properties and functions accessible to a template:
A ScriptObject
is mainly an extended version of a IDictionary<string, object>
:
var scriptObject1 = new ScriptObject();
scriptObject1.Add("var1", "Variable 1");
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is var1: `{{var1}}`");
var result = template.Render(context);
// Prints: This is var1: `Variable 1`
Console.WriteLine(result);
Note that any IDictionary<string, object>
put as a property will be accessible as well.
Via ScriptObject.Import(member, Delegate)
. Here we import a Func<string>
:
var scriptObject1 = new ScriptObject();
// Declare a function `myfunc` returning the string `Yes`
scriptObject1.Import("myfunc", new Func<string>(() => "Yes"));
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is myfunc: `{{myfunc}}`");
var result = template.Render(context);
// Prints: This is myfunc: `Yes`
Console.WriteLine(result);
You can easily import static methods declared in a .NET class via ScriptObject.Import(typeof(MyFunctions))
Let's define a class with a static function Hello
:
public static class MyFunctions
{
public static string Hello()
{
return "hello from method!";
}
}
This function can be imported into a ScriptObject:
var scriptObject1 = new ScriptObject();
scriptObject1.Import(typeof(MyFunctions));
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is MyFunctions.Hello: `{{hello}}`");
var result = template.Render(context);
// Prints This is MyFunctions.Hello: `hello from method!`
Console.WriteLine(result);
Notice that when using a function with pipe calls like
{{description | string.strip }}
, the last argument passed to thestring.strip
function is the result of the previous pipe. That's a reason why you will notice in all builtin functions in scriban that they usually take the most relevant parameter as a last parameter instead of the first parameter, to allow proper support for pipe calls.
NOTICE
By default, Properties and static methods of .NET objects are automatically exposed with lowercase and
_
names. It means that a property likeMyMethodIsNice
will be exposed asmy_method_is_nice
. This is the default convention, originally to match the behavior of liquid templates. If you want to change this behavior, you need to use aMemberRenamer
delegate
When inheriting from a ScriptObject
, the inherited object will automatically import all public static methods and properties from the class:
// We simply inherit from ScriptObject
// All functions defined in the object will be imported
public class MyCustomFunctions : ScriptObject
{
public static string Hello()
{
return "hello from method!";
}
[ScriptMemberIgnore] // This method won't be imported
public static void NotImported()
{
// ...
}
}
Then using directly this custom ScriptObject
as a regular object:
var scriptObject1 = new MyCustomFunctions();
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is MyFunctions.Hello: `{{hello}}`");
var result = template.Render(context);
// Prints This is MyFunctions.Hello: `hello from method!`
Console.WriteLine(result);
Notice that if you want to ignore a member when importing a .NET object or .NET class, you can use the attribute ScriptMemberIgnore
NOTE: Because Scriban doesn't support Function overloading, it is required that functions imported from a type must have different names.
NOTICE
By default, Properties and methods of .NET objects are automatically exposed with lowercase and
_
names. It means that a property likeMyMethodIsNice
will be exposed asmy_method_is_nice
. This is the default convention, originally to match the behavior of liquid templates. If you want to change this behavior, you need to use aMemberRenamer
delegate
Scriban runtime supports regular function arguments, optional arguments (with a default value) and params XXX[] array
arguments:
// We simply inherit from ScriptObject
// All functions defined in the object will be imported
public class MyCustomFunctions : ScriptObject
{
// A function an optional argument
public static string HelloOpt(string text, string option = null)
{
return $"hello {text} with option:{option}";
}
// A function with params
public static string HelloArgs(params object[] args)
{
return $"hello {(string.Join(",", args))}";
}
}
Using the function above from a script could be like this:
input
{{ hello_opt "test" }}
{{ hello_opt "test" "my_option" }}
{{ hello_opt "test" option: "my_option" }}
{{ hello_opt text: "test" }}
{{ hello_args "this" "is" "a" "test"}}
{{ hello_args "this" "is" args: "a" args: "test"}}
output
hello test with option:
hello test with option:my_option
hello test with option:my_option
hello test with option:
hello this,is,a,test
hello this,is,a,test
Notice that we can have a mix of regular and named arguments, assuming that named arguments are always coming last when calling a function.
Also, we can see that named arguments are also working with params
arguments.
If a regular argument (not optional) is missing, the runtime will complain about the missing argument giving precise source location of the error.
A nested ScriptObject can be accessed indirectly through another ScriptObject
:
var scriptObject1 = new ScriptObject();
var nestedObject = new ScriptObject();
nestedObject["x"] = 5;
scriptObject1.Add("subObject", nestedObject);
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is Hello: `{{subObject.x}}`");
template.Render(context);
The properties/functions of a ScriptObject
can be imported into another instance.
var scriptObject1 = new ScriptObject();
scriptObject1.Add("var1", "Variable 1");
var scriptObject2 = new ScriptObject();
scriptObject2.Add("var2", "Variable 2");
// After this command, scriptObject2 contains var1 and var2
// But modifying var2 on scriptObject2 will not modify var2 on scriptObject1!
scriptObject2.Import(scriptObject1);
You can easily import a .NET object instance (including its public properties and static methods) into a ScriptObject
NOTE that when importing into a ScriptObject, the import actually copies the property values into the ScriptObject. The original .NET object is no longer used.
Importing a .NET object instance is thus different from accessing a .NET object instance through a ScriptObject.
Let's define a standard .NET object:
public class MyObject
{
public MyObject()
{
Hello = "hello from property!";
}
public string Hello { get; set; }
}
and import the properties/functions of this object into a ScriptObject, via ScriptObject.Import(object)
:
var scriptObject1 = new ScriptObject();
scriptObject1.Import(new MyObject());
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is Hello: `{{hello}}`");
var result = template.Render(context);
// Prints This is MyFunctions.Hello: `hello from method!`
Console.WriteLine(result);
Also any objects inheriting from IDictionary<TKey, TValue>
or IDictionary
will be also accessible automatically. Typically, you can usually access directly any generic JSON objects that was parsed by a JSON library.
NOTICE
By default, Properties and static methods of .NET objects are automatically exposed with lowercase and
_
names. It means that a property likeMyMethodIsNice
will be exposed asmy_method_is_nice
. This is the default convention, originally to match the behavior of liquid templates. If you want to change this behavior, you need to use aMemberRenamer
delegate
This is an important feature of scriban. Every .NET objects made accessible through a ScriptObject is directly accessible without importing it. It means that Scriban will directly work on the .NET object instance instead of a copy (e.g when we do a ScriptObject.Import
instead)
Note that for security reason, only the properties of .NET objects accessed through another
ScriptObject
are made accessible from a Template. Methods and static methods are not automatically imported.
For example, if we re-use the previous MyObject
directly as a variable in a ScriptObject
:
var scriptObject1 = new ScriptObject();
// Notice: MyObject is not imported but accessible through
// the variable myobject
scriptObject1["myobject"] = new MyObject();
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse("This is Hello: `{{myobject.hello}}`");
var result = template.Render(context);
// Prints This is MyFunctions.Hello: `hello from method!`
Console.WriteLine(result);
NOTICE
By default, Properties and static methods of .NET objects are automatically exposed with lowercase and
_
names. It means that a property likeMyMethodIsNice
will be exposed asmy_method_is_nice
. This is the default convention, originally to match the behavior of liquid templates. If you want to change this behavior, you need to use aMemberRenamer
delegate
Runtime equivalent of the language readonly <var>
statement, you can easily define a variable of a ScriptObject
as read-only
var scriptObject1 = new ScriptObject();
// The variable `var1` is immutable
scriptObject1.SetValue("var1", "My immutable variable", true);
// Or or an existing property/function member:
scriptObject1.SetReadonly("var1", true);
For example, all builtin functions object of Scriban are imported easily by inheriting from a ScriptObject
:
- The
BuilinsFunctions
object defined here and listed here is directly used as the bottom level stackScriptObject
as explained below. - Each sub function objects (e.g
array
,string
) are also regularScriptObject
. For example, thestring
builtin functions
The current builtin ScriptObject
defined for a TemplateContext
is accessible through the TemplateContext.BuiltinObject
property.
See section about ScriptObject advanced usages also for more specific usages.
A TemplateContext
maintains a stack of ScriptObject
that defines the state of the variables accessible from the current template.
When evaluating a template and resolving a variable, the TemplateContext
will lookup to the stack of ScriptObject
for the specified variable. From the top of the stack (the latest PushGlobal
) to the bottom of the stack, when a variable is accessed from a template, the closest variable in the stack will be returned.
By default, the TemplateContext
is initialized with a builtin ScriptObject
which contains all the default builtin functions provided by scriban. You can pass your own builtin object if you want when creating a new TemplateContext
.
Then, each time you do a TemplateContext.PushGlobal(scriptObject)
, you push a new ScriptObject
accessible for resolving variable
Let's look at the following example:
// Creates scriptObject1
var scriptObject1 = new ScriptObject();
scriptObject1.Add("var1", "Variable 1");
scriptObject1.Add("var2", "Variable 2");
// Creates scriptObject2
var scriptObject2 = new ScriptObject();
// overrides the variable "var2"
scriptObject2.Add("var2", "Variable 2 - from ScriptObject 2");
// Creates a template with (builtins) + scriptObject1 + scriptObject2 variables
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
context.PushGlobal(scriptObject2);
var template = Template.Parse("This is var1: `{{var1}}` and var2: `{{var2}}");
var result = template.Render(context);
// Prints: "This is var1: `Variable 1` and var2: `Variable 2 - from ScriptObject 2"
Console.WriteLine(result);
The TemplateContext
stack is setup like this: scriptObject2
=> scriptObject1
=> builtins
As you can see the variable var1
will be resolved from scriptObject1
but the variable var2
will be resolved from scriptObject2
as there is an override here.
NOTE If a variable is not found, the runtime will not throw an error but will return
null
instead. It allows to check for a variable existenceif !page
for example. In case you want your script to throw an exception if a variable was not found, you can specifyTemplateContext.StrictVariables = true
to enforce checks. See the safe runtime section for more details.
When writing to a variable, only the ScriptObject
at the top of the TemplateContext
will be used. This top object is accessible through TemplateContext.CurrentGlobal
property. It the previous example, if we had something like this in a template:
var template2 = Template.Parse("This is var1: `{{var1}}` and var2: `{{var2}}`{{var2 = 5}} and new var2: `{{var2}}");
var result = template2.Render(context);
// Prints: "This is var1: `Variable 1` and var2: `Variable 2 - from ScriptObject 2 and new var2: `5`"
Console.WriteLine(result);
The scriptObject2
object will now contain the var2 = 5
The stack provides a way to segregate variables between their usages or read-only/accessibility/mutability requirements. Typically, the builtins
ScriptObject
is a normal ScriptObject
that contains all the builtins objects but you cannot modify directly the builtins
object. But you could modify the sub-builtins objects.
For example, the following code adds a new property myprop
to the builtin object string
:
{{
string.myprop = "Yoyo"
}}
Because scriban allows you to define new functions directly into the language and also allow to store a function pointer by using the alias @
operator, you can basically extend an existing object with both properties and functions.
When using the with
statement with a script object, it is relying on this concept of stack:
with <scriptobject>
is equivalent of callingTemplateContext.PushGlobal(scriptObject)
- Assigning a variable enclosed by a
with
statement will set variable on the target object of thewith
statement. - Ending a with is equivalent of calling
context.PopGlobal()
var scriptObject1 = new ScriptObject();
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var template = Template.Parse(@"
Create a variable
{{
myvar = {}
with myvar # Equivalent of calling context.PushGlobal(myvar)
x = 5 # Equivalent to set myvar.x = 5
y = 6
end # Equivalent of calling context.PopGlobal()
}}");
template.Render(context);
// Contains 5
Console.WriteLine(((ScriptObject)scriptObject1["myvar"])["x"]);
By default, .NET objects accessed through a ScriptObject
are automatically exposed with lowercase and _
names. It means that a property like MyMethodIsNice
will be exposed as my_method_is_nice
. This is the default convention, originally to match the behavior of liquid
templates.
A renamer is simply a delegate that takes an input MemberInfo and return a new member name:
namespace Scriban.Runtime
{
public delegate string MemberRenamerDelegate(MemberInfo member);
}
The StandardMemberRenamer
is used to convert string camel/pascal case strings to "ruby" like strings.
If you want to import a .NET object without changing the cases, you can use the simple nop member renamer member => member.Name
.
Note that renaming can be changed at two levels:
-
When importing a .NET object into a
ScriptObject
by passing a renamer delegate, before passing an object to aTemplateContext
:var scriptObject1 = new ScriptObject(); // Here the renamer will just return a same member name as the original // hence importing .NET member name as-is scriptObject1.Import(new MyObject(), renamer: member => member.Name); var context = new TemplateContext(); context.PushGlobal(scriptObject1); var template = Template.Parse("This is Hello: `{{Hello}}`"); var result = template.Render(context); // Prints This is MyFunctions.Hello: `hello from method!` Console.WriteLine(result);
-
By setting the default member renamer on the
TemplateContext
// Setup a default renamer at the `TemplateContext` level var context = new TemplateContext {MemberRenamer = member => member.Name};
It is important to setup this on the
TemplateContext
for any .NET objects that might be accessed indirectly through anotherScriptObject
so that when a .NET object is exposed, it is exposed with the correct naming convention.
The method Template.Render(object, renamer)
takes also a member renamer, imports the object model with the renamer and setup correctly the renamer on the underlying TemplateContext
.
So you can rewrite the previous example with the shorter version:
var template = Template.Parse("This is Hello: `{{Hello}}`");
template.Render(new MyObject(), member => member.Name);
Similar to the member renamer, by default, .NET objects accessed through a ScriptObject
are automatically exposing all public instance fields and properties of .NET objects.
A filter is simply a delegate that takes an input MemberInfo and return a boolean to indicate whether to expose the member (true
) or discard the member (false
)
namespace Scriban.Runtime
{
/// <summary>
/// Allows to filter a member while importing a .NET object into a ScriptObject
/// or while exposing a .NET instance through a ScriptObject,
/// by returning <c>true</c> to keep the member; or false to discard it.
/// </summary>
/// <param name="member">A member info</param>
/// <returns><c>true</c> to keep the member; otherwise <c>false</c> to remove the member</returns>
public delegate bool MemberFilterDelegate(MemberInfo member);
}
-
You can use a MemberFilter when importing a an instance:
var scriptObject1 = new ScriptObject(); // Imports only properties that contains the word "Yo" scriptObject1.Import(new MyObject(), filter: member => member is PropertyInfo && member.Name.Contains("Yo"));
-
By setting the default member filter on the
TemplateContext
, so that .NET objects automatically exposed via aScriptObject
will follow the global filtering rules defined on the context:// Setup a default filter at the `TemplateContext` level var context = new TemplateContext {MemberFilter = member => member is PropertyInfo && member.Name.Contains("Yo") };
As for the member renamer, it is important to setup this on the TemplateContext
for any .NET objects that might be accessed indirectly through another ScriptObject
so that when a .NET object is exposed, it is exposed with the same filtering convention
The include
directives requires that a template loader is setup on the TemplateContext.TemplateLoader
property
A template loader is responsible for providing the text template from an include directive. The interface of a ITemplateLoader
is defined like this:
/// <summary>
/// Interface used for loading a template.
/// </summary>
public interface ITemplateLoader
{
/// <summary>
/// Gets an absolute path for the specified include template name. Note that it is not necessarely a path on a disk,
/// but an absolute path that can be used as a dictionary key for caching)
/// </summary>
/// <param name="context">The current context called from</param>
/// <param name="callerSpan">The current span called from</param>
/// <param name="templateName">The name of the template to load</param>
/// <returns>An absolute path or unique key for the specified template name</returns>
string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName);
/// <summary>
/// Loads a template using the specified template path/key.
/// </summary>
/// <param name="context">The current context called from</param>
/// <param name="callerSpan">The current span called from</param>
/// <param name="templatePath">The path/key previously returned by <see cref="GetPath"/></param>
/// <returns>The content string loaded from the specified template path/key</returns>
string Load(TemplateContext context, SourceSpan callerSpan, string templatePath);
}
In order to use the include
directive, the template loader should provide:
- The
GetPath
method translates atemplateName
(the argument passed to theinclude <templateName>
directive) to a logical/phyisical path that theITemplateLoader.Load
method will understand. - The
Load
method to actually load the the text template code from the specifiedtemplatePath
(previously returned byGetPath
method)
The 2 step methods, GetPath
and then Load
allows to cache intermediate results. If a template loader returns the same template path
for a template name
any existing cached templates will be returned instead. Cached templates are stored in the TemplateContext.CachedTemplates
property.
A typical implementation of ITemplateLoader
could read data from the disk:
```C#
/// <summary>
/// A very simple ITemplateLoader loading directly from the disk, without any checks...etc.
/// </summary>
public class MyIncludeFromDisk : ITemplateLoader
{
string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
{
return Path.Combine(Environment.CurrentDirectory, templateName);
}
string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
// Template path was produced by the `GetPath` method above in case the Template has
// not been loaded yet
return File.ReadAllText(templatePath);
}
}
- The
Lexer
class is responsible for extractingTokens
from a text template. - The
Parser
class is responsible for creatingScriptNode
AST from input tokens (extracted from theLexer
)
The lexer has a few LexerOptions
to control the way the lexer is behaving, as described with the parsing modes
The parser has a ParserOptions
only used for securing nested statements/blocks to avoid any stack overflow exceptions while parsing a document.
The base object used by the syntax for all scriban elements is the class Scriban.Syntax.ScriptNode
:
/// <summary>
/// Base class for the abstract syntax tree of a scriban program.
/// </summary>
public abstract class ScriptNode
{
/// <summary>
/// The source span of this node.
/// </summary>
public SourceSpan Span;
/// <summary>
/// Evaluates this instance with the specified context.
/// </summary>
/// <param name="context">The template context.</param>
public abstract object Evaluate(TemplateContext context);
}
As you can see, each ScriptNode
contains a method to evaluate it against a TemplateContext
. You can go through the all the Syntax classes in the codebase and you will see that it is very easy to create a new SyntaxNode
Scriban allows to write back an AST to a textual representation:
var template = Template.Parse("This is a {{ name }} template");
// Prints "This is a {{name}} template"
Console.WriteLine(template.ToText());
In the previous example, you can notice that whitespace were removed from the original template. The reason is by default, the parser doesn't keep all hidden symbols when parsing, to still allow fast parsing for the regular case.
But you can specify the parser to keep all the hidden symbols from the original template, directly by activating the IsKeepTrivia
on the LexerOptions
In the following example, you can see that it keep all the whitespace and comment:
// Specifying the KeepTrivia allow to keep as much as hidden symbols from the original template (white spaces, newlines...etc.)
var template = Template.Parse(@"This is a {{ name + ## With some comment ## '' }} template", lexerOptions: new LexerOptions() { KeepTrivia = true });
// Prints "This is a {{ name + ## With some comment ## '' }} template"
Console.WriteLine(template.ToText());
You may need to extend a TemplateContext
to overrides some methods there, tyically in cases you want:
- To hook into whenever a
ScriptNode
AST node is evaluated - To catch if a property/member is accessed and should not be null
- Provides a
IObjectAccessor
for non .NET, nonDictionary<string, object>
in case you are looking to expose a specific object to the runtime that requires a specific access pattern. By overriding the methodGetMemberAccessorImpl
you can override this aspect. - To override
ToString(span, object)
method to provide customToString
for specifics .NET objects. - ...etc.
It is sometimes required for a custom function to have access to the current TemplateContext
or to tha access to original location of the text code, where a particular expression is occurring (via a SourceSpan
that gives a line
, column
and sourcefile
)
In the ScriptObject
section we described how to easily import a custom function either by using a delegate or a pre-defined .NET static functions/properties.
In some cases, you also need to have access to the current TemplateContext
and also, the current SourceSpan
(original location position in the text template code).
By simply adding as a first parameter TemplateContext
, and optionally as a second parameter, a SourceSpan
a custom function can have access to the current evaluation context:
var scriptObject1 = new ScriptObject();
// Here, we can have access to the `TemplateContext`
scriptObject1.Import("contextAccess", new Func<TemplateContext, string>(templateContext => "Yes"));
Some custom functions can require deeper access to the internals for exposing a function. Scriban provides the interface IScriptCustomFunction
for this matter. If an object inherits from this interface and is accessed another ScriptObject
, it will call the method IScriptCustomFunction.Invoke
.
namespace Scriban.Runtime
{
/// <summary>
/// Allows to create a custom function object.
/// </summary>
public interface IScriptCustomFunction
{
/// <summary>
/// Calls the custom function object.
/// </summary>
/// <param name="context">The template context</param>
/// <param name="callerContext">The script node originating this call</param>
/// <param name="parameters">The parameters of the call</param>
/// <param name="blockStatement">The current block statement this call is made</param>
/// <returns>The result of the call</returns>
object Invoke(TemplateContext context, ScriptNode callerContext, ScriptArray parameters, ScriptBlockStatement blockStatement);
}
}
As you can see, the IScriptCustomFunction
gives you access to:
- The current
TemplateContext
evaluating the currentTemplate
- The AST node context from the
Template
that is calling this custom functions, so you can precisely get information about the location of the parameters in the original source code...etc. - The parameters already evaluated
- The block statement (not yet used for custom functions - but used by the
wrap
statement)
The include
expression is typically implemented via a IScriptCustomFunction
. You can have a look at the details here
It is sometimes convenient to evaluate a script expression without rendering it to a string.
First, there is an option in TemplateContext.EnableOutput
that can be set to disable the output to the TemplateContext.Output
StringBuilder.
Also, as in the Abstract Syntax Tree section, all AST ScriptNode
have an Evaluate
method that returns the result of an evaluation.
Lastly, you can use the convenient static method Template.Evaluate
to quickly evaluate an expression relative to a TemplateContext
:
var scriptObject1 = new ScriptObject();
scriptObject1.Add("var1", 5);
var context = new TemplateContext();
context.PushGlobal(scriptObject1);
var result = Template.Evaluate("var1 * 5 + 2", context);
// Prints `27`
Console.WriteLine(result);
When using Template.Evaluate
, the underlying code will use the ScriptMode.ScriptOnly
when compiling the expression and will disable the output on the TemplateContext
.
The default culture when running a template is CultureInfo.InvariantCulture
You can change the culture that is used when rendering numbers/date/time and parsing date/time by pushing a new Culture to a TemplateContext
var context = new TemplateContext();
context.PushCulture(CultureInfo.CurrentCulture);
// ...
context.PopCulture();
Notice that the parsing of numbers in the language is not culture dependent but is baked into the language specs instead.
The TemplateContext
provides a few properties to control the runtime and make it safer. You can tweak the following properties:
LoopLimit
(default is1000
): If a script performs a loop over 1000 iteration, the runtime will throw aScriptRuntimeException
RecursiveLimit
(default is100
): If a script performs a recursive call over 100 depth, the runtime will throw aScriptRuntimeException
StrictVariables
(default isfalse
): If set totrue
, any variables that were not found during variable resolution will throw aScriptRuntimeException
RegexTimeOut
(default is10s
): If a builtin function is using a regular expression that is taking more than 10s to complete, the runtime will throw an exception