Template syntax
Liquid markup reference: outputs, tags, filters, whitespace control, and ZotLit's custom vocabulary.
This page documents the Liquid template language as configured in ZotLit: output expressions, tags, filters, whitespace control, the ZotLit-specific vocabulary, output coercion, includes, and template file naming.
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.
Outputs and tags
Liquid uses two delimiters:
| Syntax | Purpose |
|---|---|
{{ expression }} | Render a value into the note |
{% tag %} | Execute logic without producing output |
{{ zt.title }}
{% if zt.comment %}
{{ zt.comment }}
{% endif %}Use {% comment %}...{% endcomment %} to suppress a section entirely:
{% comment %}
Draft: revisit multi-author rendering
{% endcomment %}Truthiness
Only nil (null/missing) and false are falsy in Liquid. 0, "", and empty arrays are truthy.
ZotLit normalizes empty-string field values to null before rendering. This means {% if zt.comment %} is false when there is no comment and true when there is one. No != "" check is needed.
Whitespace control
Liquid renders whitespace exactly as written. Nothing is trimmed automatically.
Add a - inside a delimiter to trim on that side:
| Marker | Effect |
|---|---|
{%- / {{- | Eats same-line indentation before the tag. Never consumes a bare newline. |
-%} / -}} | Eats inline blanks plus exactly one following newline. A blank line after it survives. |
{% for note in zt.notes -%}
- {{ note.noteLink }}
{% endfor %}The trailing -%} on the for tag consumes the newline immediately after it, so each iteration starts on - . A blank line placed after a -%} marker still survives. The marker only ever removes one newline.
There is no vault-level whitespace setting for Liquid. Trimming is explicit per tag, which keeps templates portable across vaults.
Eta has a separate autoTrim setting
The Eta engine has its own trim-before/trim-after settings. Those do not apply to Liquid templates. See Eta syntax.
The {% liquid %} statement block
For multi-line logic without whitespace leakage, {% liquid %} accepts one statement per line with no delimiters. Nothing is emitted except what you explicitly echo:
{% liquid
assign cites = "" | split: ","
for c in zt.citations
if c.suppressAuthor
assign cite = "-@" | append: c.item.citationKey
else
assign cite = "@" | append: c.item.citationKey
endif
assign cites = cites | push: cite
endfor
echo cites | join: "; "
%}Indentation inside the block is ignored.
echo bypasses output coercion
{% echo %} inside a {% liquid %} block does not pass through the output coercion that {{ }} applies. Use {{ }} outputs when you need null-to-empty and date normalization.
Control flow
if / elsif / else / endif
{% if zt.abstract %}
> {{ zt.abstract }}
{% elsif zt.comment %}
> {{ zt.comment }}
{% else %}
> (no abstract)
{% endif %}unless / endunless
{% unless zt.notes.size > 0 %}
No child notes.
{% endunless %}for / endfor
{% for c in zt.creators limit: 3 offset: 1 reversed %}
- {{ c.fullName }}
{% endfor %}for accepts limit:, offset:, and reversed modifiers.
case / when / endcase
{% case zt.itemType %}
{% when "journalArticle" %}
Journal article
{% when "book", "bookSection" %}
Book
{% else %}
Other
{% endcase %}assign and capture
{% assign year = zt.dateAdded | date: "%Y" %}
{% capture heading %}Notes from {{ year }}{% endcapture %}
## {{ heading }}Standard filters
Filters transform a value with | and accept arguments after :.
Array filters
map, where, compact, join, sort, first, last, size, push, uniq, group_by
{{ zt.attachments | map: "fileLink" | compact | join: " " }}
{{ zt.creators | map: "lastName" | join: ", " }}String filters
append, prepend, split, strip, upcase, downcase, replace, truncate, default
{{ zt.citationKey | default: zt.DOI | default: zt.title | default: zt.key }}
{{ zt.title | truncate: 60 }}default returns the argument when the input is nil, false, or empty string.
ZotLit vocabulary
ZotLit registers a small set of tags and filters for literature-note tasks.
{% bq %}: blockquote block
Wraps its content in a Markdown blockquote. Every line is prefixed with > . The content is trimmed first. Consecutive blank lines inside the block collapse into a single bare >.
{% bq %}
[!note] Page {{ zt.pageLabel }}
{{ zt.imgLink | embed }}{{ zt.text }}
{% if zt.comment %}
{{ zt.comment }}
{% endif %}
{% endbq %}Rendered output:
> [!note] Page 42
>
> ![[excerpt.png]]The highlighted passage
>
> My thoughts on thisembed: embed filter
Prefixes a non-empty string with ! to produce the Obsidian embed syntax. Null, undefined, empty string, or non-string values collapse to "".
{{ zt.imgLink | embed }}| Input | Output |
|---|---|
"[[excerpt.png]]" | "![[excerpt.png]]" |
null | "" |
"" | "" |
Link filters
Three filters produce Obsidian wiki-links with optional alias and subpath overrides:
| Filter | Produces |
|---|---|
file_link | Attachment file link |
note_link | Note link |
img_link | Excerpt-image link |
Each accepts two optional positional arguments: alias and subpath.
{{ zt.attachments[0] | file_link: "Open the PDF" }}
{{ zt | note_link: "See notes", "#Summary" }}
{{ zt | img_link: "alt text" }}When the link cannot be resolved, the filter returns null (which compact drops from an array, and output coercion renders as "").
note_links: batch note links
Maps an array of items to their note links. Items whose link cannot be resolved render as a zt-error:<indexedKey> marker instead of being dropped silently.
{{ zt.relatedItems | note_links | join: ", " }}collection_paths: collection path strings
Maps an array of collections to their joined path strings. The default separator is /.
{{ zt.collections | collection_paths }}
{{ zt.collections | collection_paths: " > " }}{% suffix %}: filename collision guard
Meaningful only in the filename template. Emits nothing when the generated filename is unique. When it would collide with an existing note, it appends a random string.
{{ zt.citationKey | default: zt.DOI | default: zt.title | default: zt.key }}{% suffix %}- First note named
Smith2020producesSmith2020.md(no suffix). - A second note that would also render
Smith2020producesSmith2020_a1b2c3.md.
Arguments are positional: length, prepend, append (all optional).
| Argument | Default | Constraints |
|---|---|---|
length | 6 | Integer, 1–64 |
prepend | "_" | May not contain : or % |
append | "" | May not contain : or % |
{% suffix 10 %}
{% suffix 6, "(", ")" %}The suffix is a sentinel resolved at note-creation time. It resolves to prepend + <random> + append on collision, or "" when the filename is free.
date: date formatting filter
Accepts a strftime-style format string. Normalizes all date shapes ZotLit provides:
| Input type | Behavior |
|---|---|
Temporal.Instant | Converted to local-time date before formatting |
Temporal.PlainDate / PlainYearMonth | Converted to local-time date before formatting |
Zotero ItemDate (kind "date" / "yearMonth" / "year") | Converted to local-time date before formatting |
Zotero ItemDate (kind "text") | Rendered as its raw text unchanged, ignoring the format string |
{{ zt.dateAdded | date: "%Y-%m-%d" }}
{{ zt.date | date: "%B %Y" }}An ItemDate with kind "text" represents a date Zotero could not parse (e.g. "submitted"). The date filter returns it as-is.
Output coercion
Values rendered through {{ }} are coerced to text:
| Value | Rendered as |
|---|---|
null / undefined | "" (empty string, never the literal text "null") |
Date | ISO 8601 string (toISOString()) |
Temporal.Instant | Local plain date, e.g. 2026-06-21 |
| Everything else | String() (the value's own toString()) |
This coercion applies only to {{ }} outputs. {% echo %} inside a {% liquid %} block bypasses it.
Includes with {% render %}
{% render %} composes templates by name with an isolated scope:
{% render "content" with zt as zt %}
{% render "annotation" with annotation as zt %}with <value> as zt binds the value to zt inside the child template. The child sees only what you pass. Parent variables are not inherited.
The name resolves to a registered template: "annotation" looks up zotlit-annotation.liquid.md. It is a name, not a file path.
Template file naming
Template files must sit directly inside the configured template folder. Files in subfolders are not scanned.
Template files follow a strict naming pattern:
zotlit-<name>.<language>.md<name>is one or more alphanumeric characters or hyphens ([A-Za-z0-9-]+).<language>isliquidoreta.- The pattern is case-sensitive.
The six canonical names (filename, note, annotation, content, cite, cite2) each bind to a template type. A file with a canonical name overrides that type's built-in default.
Files with non-canonical names are compiled and available as partials via {% render "<name>" %}.
Liquid-wins shadowing
When both a .liquid.md and a .eta.md file exist for the same template name, the Liquid file takes precedence. The Eta file is ignored regardless of whether JavaScript templates are enabled.
Settings > Templates reports a warning on the shadowed file:
The Eta template at {path} is ignored because a Liquid template with the same name takes precedence.
This is the only shadowing rule. There is no priority between different canonical names or between canonical and non-canonical files.