Template syntax
Liquid markup reference: outputs, tags, filters, whitespace control, and ZotLit's custom vocabulary.
Available sinceZotLit 2.0.0
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: marker containing the object's key 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: " > " }}arr_prefix: prepend to every element
Prepends a string to every element of an array. Non-array input raises a render error.
{{ zt.creators | map: "lastName" | arr_prefix: "@" }}The prefix argument defaults to an empty string when omitted.
arr_suffix: append to every element
Appends a string to every element of an array. Non-array input raises a render error.
{{ zt.creators | map: "lastName" | arr_suffix: "!" }}The suffix argument defaults to an empty string when omitted.
arr_replace: replace in every element
Replaces every occurrence of a literal substring in every element of an array.
{{ zt.collections | collection_paths | arr_replace: "/", " > " }}| Argument | Required | Default | Description |
|---|---|---|---|
search | Yes | — | Literal substring to find. Must not be empty |
replacement | No | "" | Replacement text. Omit to delete the search string |
Non-array input raises a render error. An empty or missing search string raises a render error (an empty search would insert the replacement between every character).
obsidian_tag: convert to Obsidian tag
Converts arbitrary text into a valid Obsidian tag, with an optional prefix.
{{ zt.tags | obsidian_tag: "#" }}
{{ zt.tags | obsidian_tag: "zotero/" }}Accepts an array (maps element-wise) or a single value (returns a single value). Reads the name property of a Zotero tag object, so zt.tags can be piped in without map: "name" first.
Normalization rules:
- Each run of characters Obsidian rejects is replaced with one underscore.
- Repeated
/collapses to a single/. Leading and trailing_and/are trimmed. - A name that is entirely digits gets a leading underscore (
1984becomes_1984), because Obsidian rejects an all-digit tag. - A value that keeps nothing after normalization is dropped from an array, or returns an empty string for a single value.
- The optional prefix is added after normalization and stays verbatim. Use
"#"for a tag in the note body, or"zotero/"for a nested tag. Because a leading#in the input is stripped during normalization,obsidian_tag: "#"is safe to apply more than once. - Case is preserved. Obsidian matches tags case-insensitively but displays the casing a tag was first created with.
Character handling: Obsidian defines valid tag characters as a reject-list, not an allow-list. Characters outside the rejected set are left as-is. This includes CJK text and CJK punctuation, fullwidth forms, symbols such as ©, and emoji. A zero-width-joiner emoji such as 👩💻 becomes 👩_💻, because the joiner is one of the characters Obsidian rejects.
| Input (name) | Output | Notes |
|---|---|---|
Machine Learning | Machine_Learning | Space replaced |
(draft) papers | draft_papers | Parentheses and space collapsed |
R & D | R_D | Punctuation run collapsed |
文献、综述 | 文献、综述 | CJK text and punctuation kept |
/Reading/ | Reading | Edge slashes trimmed |
a//b | a/b | Repeated slash collapsed |
1984 | _1984 | All-digit name prefixed |
a/1984 | a/1984 | Non-digit present, no prefix needed |
#todo (prefix #) | #todo | Leading # stripped, then prefix re-added |
!!! | (dropped) | Nothing survives normalization |
obsidian_tag normalizes for Obsidian's tag rules. For free-text array transformations that do not need tag-specific normalization, use arr_replace.
For a managed tags frontmatter field, see Add Zotero tags to note frontmatter.
{% 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.
See also
Add Zotero tags to frontmatter
Map Zotero tags to an Obsidian tags property with Liquid.
Data reference
Properties available on zt for each template type.
Default templates
The built-in Liquid template for each type, annotated.
How templates work
Template types, languages, and the defaults-plus-eject model.
Customize a template
Eject, edit, and test a template.