Eta template syntax
Eta (JavaScript) template reference: delimiters, whitespace control, auto-filter, helpers, and the zt variable.
This page documents the Eta template language as configured in ZotLit. Eta templates require the JavaScript Templates gate to be enabled. For the default Liquid engine, see Template syntax.
All template data is accessible through a single variable, zt. The shape of zt depends on the template type: see the Data reference for the full property list.
Delimiters
| Delimiter | Purpose |
|---|---|
<% %> | Execute JavaScript (no output) |
<%= %> | Output an expression (auto-filtered) |
<%~ %> | Output an expression (auto-filtered) |
<%/* */%> | Comment (stripped from output) |
<%= %> and <%~ %> are functionally identical in ZotLit. Both pass their result through the auto-filter. Use either for output; <%= %> is conventional.
<%= zt.title %>
<% if (zt.abstract) { %>
<%= zt.abstract %>
<% } %>Use <%/* */%> for comments that do not appear in the rendered note:
<%/* Draft: revisit multi-author rendering */%>No hash-prefix comments
Eta does not support <%# %>. That syntax causes a compile error. Use <%/* */%> for all comments.
Whitespace control
By default, ZotLit sets autoTrim to [false, false]: whitespace around tags is preserved exactly as written. Newlines after %> and indentation before <% appear in the rendered note.
Per-tag markers override this default on one side of the tag:
| Marker | Position | Effect |
|---|---|---|
- | Opening (<%-) | Trims one newline before the tag |
- | Closing (-%>) | Trims one newline after the tag |
_ | Opening (<%_) | Trims all whitespace before the tag |
_ | Closing (_%>) | Trims all whitespace after the tag |
<% for (const annotation of zt.annotations) { -%>
- <%= annotation.text %>
<% } %>The trailing -%> consumes the newline immediately after the opening tag, so each iteration begins on the - line.
Global autoTrim setting
Two settings control default trim behavior when the JavaScript Templates gate is on:
- Trim whitespace before tags: applies to the opening side of every tag.
- Trim whitespace after tags: applies to the closing side of every tag.
Each accepts three values: Keep (no trim), Strip one newline, or Strip all whitespace. Per-tag markers always override the global setting for that tag.
Markdown is whitespace-sensitive
The default (keep all whitespace) avoids silently breaking Markdown formatting. Use per-tag markers for precise control.
Auto-filter (output coercion)
Every <%= %> and <%~ %> expression passes through a filter function before rendering. The filter coerces values to Markdown-safe text:
| Value | Rendered as |
|---|---|
null / undefined | "" (empty string) |
Date | ISO 8601 string (toISOString()) |
Temporal.Instant | Local plain date, e.g. 2026-06-21 |
| Everything else | String(value) |
This means <%= zt.someFieldThatIsNull %> outputs nothing rather than the literal text "null". Creator objects and dates render through their toString() method: creators produce their full name, dates produce ISO strings.
The zt variable
All template data lives under the zt variable. The variable name applies across all template types:
<%= zt.title %>
<%= zt.creators[0].family %>
<%= zt.citationKey %>The editor autocomplete inserts = zt. when you select the interpolation hint inside <%= %> tags.
zt replaces v1's it variable. See Migrate v1 Eta templates for the full property mapping.
include(): composing templates
include() renders another template inline. Pass the template name and an optional data argument:
<%~ include("annotation", annotation) %>
<%~ include("content", zt) %>The data argument becomes the child template's zt variable, direct passthrough not merged with the parent's data. When omitted, the child inherits the parent's zt.
The name resolves to a registered template: "annotation" finds zotlit-annotation.eta.md. It is a name, not a file path.
The content include
When include("content", zt) is called from the note template, ZotLit automatically wraps the output with managed-region markers (%%zt-managed%% / %%/zt-managed%%). These markers never appear in any .eta.md file. They are injected at render time.
bq(): blockquote helper
Wraps its content in a Markdown blockquote. Every line is prefixed with > . The content is trimmed. Consecutive blank lines inside the block collapse into a single bare >.
<% bq(() => { %>
[!note] Page <%= zt.pageLabel %>
<%= embed(zt.imgLink) %><%= zt.text %>
<% if (zt.comment) { %>
<%= zt.comment %>
<% } %>
<% }) %>Rendered output:
> [!note] Page 42
>
> ![[excerpt.png]]The highlighted passage
>
> My thoughts on thisbq() must appear in a <% %> execute tag. It captures all output produced inside the callback, trims the result, prefixes each line with > (empty lines become bare >), collapses consecutive bare > lines into one, and writes the result to the output.
When the captured content is empty, bq() renders a lone >.
embed(): embed helper
Turns a link helper into a Markdown embed by prefixing !. Returns "" when the link is null, undefined, or renders empty.
<%= embed(zt.imgLink) %>
<%= embed(zt.imgLink, "alt text") %>Signature: embed(link, alias?, subpath?)
| Input | Output |
|---|---|
zt.imgLink renders "[[excerpt.png]]" | "![[excerpt.png]]" |
zt.imgLink is null | "" |
suffix(): filename collision guard
For the filename template only. Appends a random string when the generated filename collides with an existing note. When the name is free, it renders nothing.
<%= zt.citationKey ?? zt.DOI ?? zt.title ?? zt.key %><%= suffix() %>- First note named
Smith2020producesSmith2020.md. - A second note that would also render
Smith2020producesSmith2020_a1b2c3.md.
Signature
suffix(length?, prepend?, append?)| Parameter | Default | Constraints |
|---|---|---|
length | 6 | Integer, 1–64 |
prepend | "_" | Must not contain : or % |
append | "" | Must not contain : or % |
The affixes appear only on collision. On a free name, the entire expression (affixes included) renders nothing.
<%= zt.title %><%= suffix(10) %>
<%= zt.title %><%= suffix(6, "(", ")") %>
<%= zt.citationKey %><%= suffix(4, "-") %>The random string uses an alphanumeric alphabet. On collision, ZotLit retries up to 5 times with fresh random strings.
Link helpers
Links on the zt context are functions, not precomputed strings. Call them to render. Each accepts optional overrides:
linkHelper(alias?, subpath?)alias: display text for the link. Omit to use the default (filename or note title).subpath: a#-fragment appended to the target, e.g."#heading"or"#page=3".
| Helper | Available on | Default subpath | Returns |
|---|---|---|---|
zt.noteLink() | item context | none | Markdown link to the item's literature note, or null |
note.noteLink() | each zt.notes[] entry | none | Link to the child note |
a.fileLink() | each zt.attachments[] entry | none | Attachment file link |
zt.fileLink() | annotation context | #page=N | Attachment link deep-linked to the annotation's page |
zt.imgLink() | annotation context | none | Excerpt-image link, or null |
<%= zt.attachments[0].fileLink() %>
<%= zt.attachments[0].fileLink("Open the PDF") %>
<%= zt.noteLink("See notes", "#Summary") %>zt.noteLink() returns null when the literature note path is unresolvable (collision, recursive resolution, or template error). zt.imgLink is null for annotation types without a cached image (everything except image and ink).
Template file naming
Template files sit directly inside the configured template folder. The naming pattern:
zotlit-<name>.<language>.md<name>: alphanumeric characters or hyphens ([A-Za-z0-9-]+).<language>:liquidoreta.
The six canonical names: filename, note, annotation, content, cite, cite2.
Each canonical name corresponds to a template file: the filename template is stored as zotlit-filename.eta.md, following the same pattern as the other five types.
Liquid-wins shadowing
When both a .liquid.md and .eta.md file exist for the same name, the Liquid file takes precedence. The Eta file is ignored.
No async
ZotLit renders templates synchronously. async/await is not available inside Eta templates.