You’re reading the ZotLit v2 beta docs. Still on v1? Read the v1 docs

ZotLit

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:

SyntaxPurpose
{{ 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:

MarkerEffect
{%- / {{-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 this

embed: 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 }}
InputOutput
"[[excerpt.png]]""![[excerpt.png]]"
null""
""""

Three filters produce Obsidian wiki-links with optional alias and subpath overrides:

FilterProduces
file_linkAttachment file link
note_linkNote link
img_linkExcerpt-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 "").

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 Smith2020 produces Smith2020.md (no suffix).
  • A second note that would also render Smith2020 produces Smith2020_a1b2c3.md.

Arguments are positional: length, prepend, append (all optional).

ArgumentDefaultConstraints
length6Integer, 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 typeBehavior
Temporal.InstantConverted to local-time date before formatting
Temporal.PlainDate / PlainYearMonthConverted 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:

ValueRendered as
null / undefined"" (empty string, never the literal text "null")
DateISO 8601 string (toISOString())
Temporal.InstantLocal plain date, e.g. 2026-06-21
Everything elseString() (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> is liquid or eta.
  • 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

On this page