Skip to main content
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>
<!-- 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') %>
<!-- 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();
});

Custom Tags (Nunjucks)

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.

Build docs developers (and LLMs) love