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 document 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 names = "" | split: ","
for creator in zt.creators
assign names = names | push: creator.lastName
endfor
echo names | 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.
{% render_annotation %}: annotation rendering
Renders one annotation, available as zt inside the annotation template.
{% render_annotation annotation %}The annotation argument is required. A missing or null value raises an error.
For an annotation value, the equivalent native Liquid syntax is:
{% render "annotation" with annotation as zt %}The named-parameter form, {% render "annotation", zt: annotation %}, is also equivalent.
In a literature note profile, the shortcut and native calls to annotation use the profile's Annotation Section. This required final section starts after the exact standalone line --- zotlit:annotation --- and continues to the end of the document. It uses the profile's language and can be empty.
Calls from shared partials use the calling profile's section, with isolated annotation data. Direct annotation insertion and Imported Notes use the same section for their applicable profile. Outside profile rendering, the shortcut keeps the existing named-template lookup.
{% 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 | "" |
"" | "" |
For zt.imgLink, the filter uses the prepared excerpt image. If no image is available, it returns an explanation and source link without adding !. See Excerpt images for saved snapshots and import outcomes.
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" }}On an annotation, file_link is deprecated. It holds the file path of the
computer that rendered the note, so it opens nothing on another computer or
after the Zotero storage folder moves. Link the annotation with its durable
link, zt.backlink, as the default annotation
template does.
zt.backlink is a plain URL, not a filter. file_link on an attachment is
not deprecated, and the annotation form stays available for the current
release line.
When a file or note link cannot be resolved, the filter returns null (which compact drops from an array, and output coercion renders as ""). For image and ink annotations, img_link returns the excerpt fallback when an image is unavailable. For other annotation types, it returns null.
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: ", " }}pandoc_cite: Pandoc Citation source
Formats a Citation Item array as Pandoc Citation source. Pass zt.citations directly to the filter.
{{ zt.citations | pandoc_cite }}
{{ zt.citations | pandoc_cite: "prefer-author-in-text" }}The optional form is "normal" by default.
| Form | Output for one item | Description |
|---|---|---|
"normal" | [@smith2024] | Produces a bracketed Citation Cluster. |
"prefer-author-in-text" | @smith2024 | Produces an Author-in-text Citation when the first retained item has no prefix and does not suppress the author. Otherwise, produces a normal cluster. |
The filter handles all Citation Item properties used in Pandoc source:
| Input | Output |
|---|---|
Locator "3" with labelShort "p." | [@smith2024, {p. 3}] |
Prefix "see " and suffix ", note 4" | [see @smith2024, note 4] |
suppressAuthor set to true | [-@smith2024] |
Citation key "KX67D9YM?" | [@{KX67D9YM?}] |
"prefer-author-in-text" with locator "3" | @smith2024 [{p. 3}] |
"prefer-author-in-text" with prefix "see " | [see @smith2024] |
| Empty array or items with null citation keys only | "" |
Items with a null citation key are omitted. The filter preserves the order of the remaining items and adds braces around complex citation keys when Pandoc requires them.
Input that cannot be represented without changing its citation structure raises a render error.
tex_cite: LaTeX citation command
Formats a Citation Item array as one LaTeX citation command. Pass zt.citations directly to the filter.
{{ zt.citations | tex_cite }}
{{ zt.citations | tex_cite: "autocite" }}The optional command is "cite" by default. Write it without the backslash. A starred command such as "autocite*" is accepted.
| Command | Output for one item | Bibliography package |
|---|---|---|
"cite" | \cite{smith2024} | LaTeX, natbib, BibLaTeX |
"autocite" | \autocite{smith2024} | BibLaTeX |
Every retained item joins one key list. The command carries one prenote and one postnote, and both come from the first retained item:
| Input | Output |
|---|---|
| Two items | \cite{smith2024,doe2023} |
Locator "3" with labelShort "p." | \cite[p. 3]{smith2024} |
Prefix "see " | \cite[see][]{smith2024} |
Prefix "see " with locator "3" | \cite[see][p. 3]{smith2024} |
Locator "3" with suffix ", note 4" | \cite[p. 3, note 4]{smith2024} |
| Empty array or items with null citation keys only | "" |
Items with a null citation key are omitted, and the remaining items keep their order.
The prenote form needs natbib or BibLaTeX
The LaTeX kernel's own \cite takes one optional argument. The two-argument
\cite[see][p. 3]{smith2024} form above compiles under natbib or BibLaTeX,
which redefine \cite to take both. A citation that carries no prefix emits
at most one optional argument and works in a plain LaTeX document.
An affix reaches LaTeX as source, so a prefix of \emph{see} renders as emphasis. These inputs raise a render error instead:
suppressAuthorset totrue, which LaTeX has no general form for.- A prefix, suffix, or locator on any item after the first, which has no slot.
- An empty citation key, or one that holds whitespace, a comma, or a TeX special character.
- A prefix, suffix, or locator whose braces do not balance, or which holds an unescaped
%.
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, not to a file path. During Profile rendering, "annotation" selects the Profile's Annotation Section.
Calling a partial
A partial is called by its name, and the data it needs travels with the call:
{% render "authors" with zt as zt %}{% render %} opens an isolated scope, so zt reaches the partial only because the call passes it by name. Renders a registered partial in an isolated scope. Pass data explicitly with named arguments or with … as …; for … as … repeats the partial for a collection.
A bare call reaches the partial with no zt, so every zt output in it comes out empty and only its literal text survives:
{% render "authors" %}{% include %} renders the same partial with the caller's variables in scope, so it needs no with clause. Renders a registered partial with access to the caller's variables. Use render when the partial needs an isolated scope.
{% include "authors" %}Completion inside {% render " and {% include " lists every partial in the vault. Accepting a name writes the whole call: render gets the with zt as zt clause, include needs none. A call you have already given arguments keeps them, and only the name is replaced.
Template document naming
A template document sits directly inside the folder set by Settings > ZotLit > Advanced > Template folder. The scan is flat. Files in subfolders are never read, and a file without the zotlit- prefix is ignored.
The filename prefix alone says what a document is:
| Filename | Document |
|---|---|
zotlit-profile.<slug>.md | A literature note profile: note name, folder, properties, body, and annotations |
zotlit-citation.md | The citation text, one per vault, under exactly this name |
zotlit-partial.<name>.md | One partial, registered under <name> |
A zotlit- file that fits none of these patterns registers nothing and is reported in the Template engine group on Settings > ZotLit > Advanced. See Unrecognized ZotLit files.
Language
A citation document or a partial is Liquid unless its manifest says otherwise. The manifest is an optional YAML block at the top of the file, and language is its only legal key:
---
language: eta
---
<%= zt.title %>The legal values are liquid and eta. A document with no manifest is Liquid. Switch a document between the two from the Template Workbench View's pane menu, with Change language; it rewrites the manifest key and leaves the source as written.
Partial names
A partial name is one or more letters, digits, and hyphens. The Add partial prompt trims what you type, turns each run of spaces into a hyphen, and lowercases the result, so Journal line becomes journal-line. Names are compared without case, so two files cannot claim one name in different casings.
Five names are reserved, because another ZotLit template already answers to each: filename, note, annotation, content, and citation. A partial file that takes one registers nothing and is reported at Settings > ZotLit > Literature note profiles > Partials. See Partials.
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 documents, languages, and where each template lives.
Customize a template
Open a template document, edit it, and test the result.
Last updated on