Opal Editor includes a powerful templating system that supports multiple template engines for generating dynamic HTML from your markdown and data. Templates enable you to create reusable layouts, include dynamic content, and build sophisticated static sites.
Supported Template Engines
Opal supports four popular template engines, each with different syntax and capabilities:
Eta Fast, lightweight, similar to EJS with async support
Liquid Shopify’s template language, safe and powerful
Mustache Logic-less templates, simple and portable
Nunjucks Mozilla’s Jinja2-inspired templating with rich features
Template Manager
The TemplateManager class handles all template operations:
// From TemplateManager.ts
export class TemplateManager {
private renderers : Record < TemplateType , BaseRenderer >;
constructor ( workspace : Workspace ) {
this . workspace = workspace ;
this . renderers = {
"text/x-ejs" : new EtaRenderer ( workspace ), // .ejs, .eta files
"text/x-mustache" : new MustacheRenderer ( workspace ), // .mustache files
"text/x-nunchucks" : new NunchucksRenderer ( workspace ), // .njk, .nunjucks files
"text/x-liquid" : new LiquidRenderer ( workspace ), // .liquid files
"text/html" : new HtmlRenderer ( workspace ), // .html files
};
}
async renderTemplate (
templatePath : AbsPath ,
customData : TemplateData = {}
) : Promise < string > {
const renderer = this . getRenderer ( templatePath );
return await renderer . renderTemplate ( templatePath , customData );
}
}
Eta Templates
File extensions: .eta, .ejs
Eta is the recommended template engine for Opal, offering the best balance of features and performance.
Basic Syntax
<!-- template.eta -->
<! DOCTYPE html >
< html >
< head >
< title > < %= data.title %> </ title >
</ head >
< body >
< h1 > < %= data.heading %> </ h1 >
< % if (data.showContent) { %>
< p > < %= data.content %> </ p >
< % } %>
< ul >
< % data.items.forEach(item => { %>
< li > < %= item %> </ li >
< % }) %>
</ ul >
</ body >
</ html >
Eta Features
Interpolation:
< %= value %> <!-- HTML-escaped output -->
< %~ value %> <!-- Raw output (no escaping) -->
Control Flow:
< % if (condition) { %>
<!-- content -->
< % } else { %>
<!-- alternative -->
< % } %>
< % for (let i = 0; i < 10; i++) { %>
< p > Item < %= i %> </ p >
< % } %>
Async Support:
< % const data = await helpers.importMarkdown('/posts/intro.md') %>
< div > < %= data.content %> </ div >
Includes:
< %~ include('header', { title: 'My Page' }) %>
< main >
<!-- page content -->
</ main >
< %~ include('footer') %>
Markdown Import:
< % const post = await helpers.importMarkdown('/posts/hello.md') %>
< article >
< h1 > < %= post.data.title %> </ h1 >
< div > < %= post.content %> </ div >
</ article >
Liquid Templates
File extension: .liquid
Liquid is a safe, designer-friendly template language originally created by Shopify.
Basic Syntax
<!-- template.liquid -->
<! DOCTYPE html >
< html >
< head >
< title >{{ data . title }}</ title >
</ head >
< body >
< h1 >{{ data . heading }}</ h1 >
{% if data . showContent %}
< p >{{ data . content }}</ p >
{% endif %}
< ul >
{% for item in data . items %}
< li >{{ item }}</ li >
{% endfor %}
</ ul >
</ body >
</ html >
Liquid Features
Variables:
{{ variable }} <!-- Output -->
{{ variable | upcase }} <!-- With filter -->
Tags:
{% if condition %}
<!-- content -->
{% elsif other_condition %}
<!-- alternative -->
{% else %}
<!-- default -->
{% endif %}
{% for item in array %}
{{ item }}
{% endfor %}
{% assign name = "value" %}
Filters:
{{ text | capitalize }} <!-- First letter uppercase -->
{{ text | truncate: 100 }} <!-- Limit length -->
{{ date | format_date: "MM/DD/YYYY" }}
{{ array | first }} <!-- Get first element -->
{{ obj | json }} <!-- Convert to JSON -->
Mustache Templates
File extension: .mustache
Mustache is a logic-less template engine that emphasizes simplicity.
Basic Syntax
<!-- template.mustache -->
<!DOCTYPE html>
<html>
<head>
<title>{{data.title}}</title>
</head>
<body>
<h1>{{data.heading}}</h1>
{{#data.showContent}}
<p>{{data.content}}</p>
{{/data.showContent}}
<ul>
{{#data.items}}
<li>{{.}}</li>
{{/data.items}}
</ul>
</body>
</html>
Mustache Features
Variables:
{{name}} <!-- HTML-escaped -->
{{{rawName}}} <!-- Unescaped -->
{{#helpers.uppercase}}{{name}}{{/helpers.uppercase}}
Sections:
<!-- Boolean sections -->
{{#person}}
Hello {{name}}!
{{/person}}
<!-- Inverted sections -->
{{^person}}
No person found.
{{/person}}
<!-- Array iteration -->
{{#items}}
<li>{{name}}: {{price}}</li>
{{/items}}
Partials:
{{> header}}
<main>
Content here
</main>
{{> footer}}
Nunjucks Templates
File extensions: .njk, .nunjucks
Nunjucks is a feature-rich template engine inspired by Jinja2.
Basic Syntax
<!-- template.njk -->
<!DOCTYPE html>
<html>
<head>
<title>{{ data.title }}</title>
</head>
<body>
<h1>{{ data.heading }}</h1>
{% if data.showContent %}
<p>{{ data.content }}</p>
{% endif %}
<ul>
{% for item in data.items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</body>
</html>
Nunjucks Features
Variables:
{{ variable }} <!-- Output -->
{{ variable | upper }} <!-- With filter -->
{{ variable | default("fallback") }}
Control Structures:
{% if condition %}
<!-- content -->
{% elif otherCondition %}
<!-- alternative -->
{% else %}
<!-- default -->
{% endif %}
{% for item in items %}
{{ loop.index }}: {{ item }}
{% endfor %}
{% set name = "value" %}
Template Inheritance:
<!-- base.njk -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
<!-- page.njk -->
{% extends "base.njk" %}
{% block title %}My Page{% endblock %}
{% block content %}
<h1>Page Content</h1>
{% endblock %}
Template Data
All templates receive a rich data context:
interface TemplateData {
// Custom data passed to template
data ?: Record < string , any >;
// All workspace images
images ?: Array <{
path : AbsPath ;
url : string ;
name : string ;
}>;
// Complete file tree
fileTree ?: Array <{
path : AbsPath ;
name : string ;
type : string ;
}>;
// Workspace metadata
workspace ?: {
name : string ;
id : string ;
};
// Helper functions
helpers ?: TemplateHelpers ;
}
Helper Functions
All templates have access to helper functions:
String Helpers
// Eta
<%= helpers . capitalize ( text ) %>
<%= helpers . uppercase ( text ) %>
<%= helpers . lowercase ( text ) %>
<%= helpers . truncate ( text , 100 , '...' ) %>
<%= helpers . slugify ( text ) %>
// Liquid
{{ text | capitalize }}
{{ text | uppercase }}
{{ text | lowercase }}
{{ text | truncate : 100 }}
{{ text | slugify }}
Array Helpers
// Eta
<%= helpers . first ( array ) %>
<%= helpers . last ( array ) %>
<%= helpers . take ( array , 5 ) %>
<%= helpers . skip ( array , 5 ) %>
// Liquid
{{ array | first }}
{{ array | last }}
{{ array | take : 5 }}
{{ array | skip : 5 }}
Date Helpers
// Eta
<%= helpers . formatDate ( date , 'MM/DD/YYYY' ) %>
<%= helpers . now () %>
// Liquid
{{ date | format_date : "MM/DD/YYYY" }}
{ % now % }
File Helpers
// Eta
<%= helpers . getFileExtension ( path ) %>
<%= helpers . getFileName ( path ) %>
<%= helpers . getFileSize ( bytes ) %>
// Liquid
{{ path | file_extension }}
{{ path | file_name }}
{{ bytes | file_size }}
Image Helpers
// Eta
<% const imageFiles = helpers . filterImages ( fileTree ) %>
<% const pngs = helpers . getImagesByType ( images , 'png' ) %>
// Liquid
{ % assign imageFiles = fileTree | filter_images % }
{ % assign pngs = images | images_by_type : "png" % }
Template Examples
Blog Post Template
<!-- post.eta -->
< % const post = await helpers.importMarkdown(data.postPath) %>
<! DOCTYPE html >
< html >
< head >
< title > < %= post.data.title %> - My Blog </ title >
< meta name = "description" content = " < %= post.data.description %>" />
</ head >
< body >
< article >
< header >
< h1 > < %= post.data.title %> </ h1 >
< time > < %= helpers.formatDate(post.data.date, 'MMMM DD, YYYY') %> </ time >
< % if (post.data.tags) { %>
< ul class = "tags" >
< % post.data.tags.forEach(tag => { %>
< li > < %= tag %> </ li >
< % }) %>
</ ul >
< % } %>
</ header >
< div class = "content" >
< %~ post.content %>
</ div >
</ article >
</ body >
</ html >
Image Gallery Template
<!-- gallery.liquid -->
<! DOCTYPE html >
< html >
< head >
< title > Photo Gallery </ title >
</ head >
< body >
< h1 >{{ data . title }}</ h1 >
< div class = "gallery" >
{% assign galleryImages = images | filter_images %}
{% for image in galleryImages %}
< div class = "photo" >
< img
src = " {{ image . url }} "
alt = " {{ image . name }} "
loading = "lazy"
/>
< p >{{ image . name }}</ p >
</ div >
{% endfor %}
</ div >
</ body >
</ html >
Site Index Template
<!-- index.njk -->
{% extends "base.njk" %}
{% block title %}Site Index{% endblock %}
{% block content %}
<h1>All Pages</h1>
<nav>
<ul>
{% for node in fileTree %}
{% if node.type == "file" and node.path.endsWith(".md") %}
<li>
<a href="{{ node.path }}">
{{ node.name | replace(".md", "") }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endblock %}
Best Practices
Eta : For complex logic and async operations
Liquid : For designer-friendly templates
Mustache : For simple, portable templates
Nunjucks : For template inheritance and advanced features
Separate layout from content: <!-- layout.eta -->
< %~ include('header') %>
< %~ body %>
< %~ include('footer') %>
Use partials for reusable components
<!-- card.eta -->
< div class = "card" >
< h3 > < %= title %> </ h3 >
< p > < %= description %> </ p >
</ div >
<!-- page.eta -->
< %~ include('card', { title: 'Title', description: 'Desc' }) %>
Templates are parsed on each render. For static content, consider pre-rendering: const html = await templateManager . renderTemplate ( '/template.eta' , data );
await workspace . writeFile ( '/output.html' , html );
Advanced Usage
Custom Filters (Liquid)
Extend Liquid with custom filters:
liquidEngine . registerFilter ( 'myFilter' , ( value : string ) => {
return value . toUpperCase ();
});
Create custom Nunjucks tags:
nunjucksEnv . addExtension ( 'CustomTag' , {
tags: [ 'customtag' ],
parse : ( parser , nodes ) => {
// Parse logic
},
run : ( context ) => {
// Runtime logic
}
});
Template Preloading (Eta)
Preload templates for better performance:
const renderer = new EtaRenderer ( workspace );
await renderer . preloadTemplates ([
'/templates/header.eta' ,
'/templates/footer.eta'
]);
Eta’s async support makes it ideal for importing markdown files and other dynamic content during template rendering.