Migrate v1 Eta templates
Rename, update variables, and rewrite custom v1 Eta templates to the v2 format, or convert them to Liquid.
Custom v1 Eta templates need renamed files and updated variables to run in v2. If you never customized your templates (using only the defaults), no action is needed. V2 ships updated defaults.
Two migration paths:
- Keep Eta: rename files, update the data vocabulary, enable the JavaScript Templates gate. The rest of this page covers this path.
- Convert to Liquid: rewrite each template in v2's default Liquid engine. No gate needed. See Eta to Liquid translations at the end.
JavaScript Templates gate required for Eta
Eta templates require the JavaScript Templates gate. See Enable JavaScript templates for the opt-in procedure.
Rename template files
v2 templates use a zotlit- prefix (replacing zt-) and the template formerly called annots is now content.
| v1 filename | v2 filename |
|---|---|
zt-note.eta.md | zotlit-note.eta.md |
zt-annots.eta.md | zotlit-content.eta.md |
zt-annot.eta.md | zotlit-annotation.eta.md |
zt-cite.eta.md | zotlit-cite.eta.md |
zt-cite2.eta.md | zotlit-cite2.eta.md |
Delete these files if present (they have no v2 equivalent):
zt-field.eta.md: replaced by the settings-based frontmatter system.zt-colored.eta.md: color styling is now handled automatically during note import.
Replace it with zt
The template data variable changed from it to zt. A search-and-replace of it. to zt. within template tags covers most cases:
it.title → zt.title
it.creators → zt.creators
it.backlink → zt.backlink
it.pageLabel → zt.pageLabelThe exceptions below (comment, tags, collections, date, notes) have changed semantics. Handle them in the following steps rather than with a blanket replace.
Update renamed and reshaped fields
These fields changed name or type:
| v1 property | v2 property | Notes |
|---|---|---|
it.citekey | zt.citationKey | zt.citekey also works as an alias |
it.abstractNote | zt.abstract | zt.abstractNote also works |
it.publicationTitle | zt.containerTitle | zt.publicationTitle also works |
it.color | zt.colorHex | Hex string, e.g. #ffd400 |
| — | zt.colorName | New: palette name ("yellow", "red", etc.) |
it.imgExcerpt | embed(zt.imgLink) | zt.imgLink is a link helper or null; embed() adds the ! prefix |
it.imgPath | (removed) | Use zt.imgLink() instead |
it.imgUrl | (removed) | Use zt.imgLink() instead |
it.textExcerpt | zt.text | Renamed |
it.commentMd | zt.comment | Markdown comment is now the default |
it.comment | zt.commentHtml | Raw HTML (only <i>/<b>/<sub>/<sup>) |
Direct Zotero fields that are plain strings (title, DOI, url, volume, issue, pages, publisher, etc.) carry over unchanged after the it → zt rename.
Update tag access
v1 exposed tags as formatted strings. v2 exposes them as objects with name and type properties. Tags coerce to their name in string contexts, so join still works:
<%/* Tags coerce to their name */%>
<%= zt.tags.join(", ") %>
<%/* Explicit property access */%>
<%= zt.tags.map(t => t.name).join(", ") %>
<%/* Filter to manual tags only */%>
<%= zt.tags.filter(t => t.type === "manual").join(", ") %>Update collection access
v2 reshapes collection objects. Key differences:
| Aspect | v1 | v2 |
|---|---|---|
path order | leaf → root | root → leaf |
path rendering | auto-rendered "A > B > C" | plain string[] — use c.path.join(" > ") |
| Singular alias | it.collection | removed — use zt.collections array |
<%/* Collections coerce to their name in string contexts */%>
<%= zt.collections.join(", ") %>
<%/* Render each collection's full hierarchy */%>
<%= zt.collections.map(c => c.path.join(" > ")).join("; ") %>Update date fields
| v1 property | v2 property | Type change |
|---|---|---|
it.date | zt.date | Was a year-only string; now a parsed ItemDate object. <%= zt.date %> renders ISO; zt.date?.year gets the numeric year. |
it.dateAdded / it.dateModified | zt.dateAdded / zt.dateModified | Were raw SQL strings ("YYYY-MM-DD HH:MM:SS"); now Temporal.Instant. Render as local date in <%= %> tags (e.g. 2026-06-21). |
If your v1 template used it.date as a year string, replace with zt.date?.year:
<%/* v1 */%>
year: "<%= it.date %>"
<%/* v2 */%>
year: "<%= zt.date?.year %>"Update child note access
v1 inlined child-note content as Markdown on it.notes. v2 writes each child note to a separate file and provides link-only entries:
| v1 | v2 |
|---|---|
it.notes — array of {title, content} | zt.notes — array of {key, title, noteLink()} |
| Note content inlined | Content in a separate file; noteLink() links to it |
The default content template renders child notes as a bulleted list of links:
<% if (zt.notes.length) { %>
## Notes
<% for (const note of zt.notes) { -%>
- <%~ note.noteLink() %>
<% } %>
<% } %>Rewrite the annotation template with bq()
v1 relied on external blockquote handling. v2 uses the bq() helper inside the annotation template:
<% bq(() => { %>
[!note] Page <%= zt.pageLabel %>
<%= embed(zt.imgLink) %><%= zt.text %>
<% if (zt.comment) { %>
<%= zt.comment %>
<% } %>
<% }) %>bq() wraps all content in a Markdown blockquote (> per line), trims the result, and collapses consecutive empty blockquote lines. It must appear in a <% %> execute tag.
Update the content template
The template is renamed from annots to content. It now receives the full context object instead of a raw annotation array:
<%/* v1: received a raw array as `it` */%>
<% for (const annotation of it) { %>
<%/* v2: receives full context, access annotations via zt.annotations */%>
<% for (const annotation of zt.annotations) { %>Update the include call in your note template:
<%/* v1 */%>
<%~ include("annots", it.annotations) %>
<%/* v2 */%>
<%~ include("content", zt) %>The content template now has access to all zt.* properties (attachments, tags, notes, etc.), not only annotations.
Update citation templates
v1 passed it as a raw array. v2 wraps items in an object:
<%/* v1 */%>
[<%= it.filter(lit => !!lit.citekey).map(lit => `@${lit.citekey}`).join("; ") %>]
<%/* v2 */%>
[<%= zt.citations.filter(c => c.item.citationKey).map(c =>
`${c.suppressAuthor ? "-" : ""}@${c.item.citationKey}`
).join("; ") %>]Key changes:
it(array) becomeszt.citations(array inside an object).zt.itemsexposes the same items without citation metadata.lit.citekeybecomesc.item.citationKey(orc.item.citekey).
Update attachment access
v1 selected one attachment and exposed it as it.fileLink (a string). v2 provides all attachments as an array, and fileLink is a link helper you call:
<%= zt.attachments.map(a => a.fileLink()).filter(Boolean).join(" ") %>Each attachment object has: key, filename, contentType, linkMode, filePath, fileLink.
Update creator access
v2 uses a flat array with explicit properties. Creators coerce to their full name in string contexts:
<%/* All authors (coerces to fullName) */%>
<%= zt.authors.join(", ") %>
<%/* Explicit property access */%>
<%= zt.authors.map(c => c.fullName).join(", ") %>
<%/* Filter by role */%>
<%= zt.creators.filter(c => c.role === "editor").join(", ") %>Creator properties: family, given, literal (institutional names), role, fullName.
Convenience properties on the item context: zt.authors (primary creator type) and zt.authorsShort (formatted short string, e.g. "Smith et al.").
Update the filename template
The filename template is a template file (zotlit-filename.eta.md), not a setting string. The v2 default adds suffix() for collision avoidance:
<%= zt.citationKey ?? zt.DOI ?? zt.title ?? zt.key %><%= suffix() %>it.citekeybecomeszt.citationKey(canonical name;zt.citekeyalso works).- The
.mdextension is no longer included. ZotLit appends it automatically. suffix()appends a random string only when the name collides with an existing note.
Migrate frontmatter fields
If you customized zt-field.eta.md, delete it and recreate the fields in Settings > ZotLit > Templates > Frontmatter:
For each field, configure:
- Key: the YAML property name.
- Expression: a JavaScript or Liquid expression evaluated against
zt. - Language: select JavaScript to use JS expressions (requires the JavaScript Templates gate), or Liquid for Liquid expressions.
- Merge strategy: Replace, Append arrays, or Keep existing.
| v1 template line | v2 frontmatter field |
|---|---|
title: "<%= it.title %>" | Key: title, Expr: zt.title, Merge: Replace |
authors: ... | Key: authors, Expr: zt.authors.map(c => c.fullName), Merge: Replace |
year: "<%= it.date %>" | Key: year, Expr: zt.date?.year, Merge: Replace |
tags: ... | Key: tags, Expr: zt.tags.map(t => t.name), Merge: Append arrays |
YAML escaping is handled automatically.
ZotLit manages reserved frontmatter keys that you cannot target from a user field. For literature notes: zotero-key and citekey. For imported notes: zotero-note-key and zotero-lastmod. See Frontmatter reference for details.
Language selector
JavaScript expressions (like zt.authors.map(...)) require selecting JavaScript in the Language dropdown for that field. The JavaScript Templates gate must be on.
Overwrite v1 notes to add the managed region
v1 notes lack the %%zt-managed%% / %%/zt-managed%% markers that v2 uses to isolate generated content. Incremental update cannot target their body. Running ZotLit: Update literature note on a v1 note refreshes frontmatter only.
To add the managed region, run the overwrite command on each note you want refreshed:
ZotLit: Overwrite literature noteA confirmation modal appears. Click Overwrite to proceed. ZotLit regenerates the entire note body from the current templates, inserting the managed-region markers.
After overwriting, the note responds to ZotLit: Update literature note for incremental updates going forward. You do not need to overwrite every note at once. Leave notes untouched until you are ready.
See Refresh v1 notes for the full procedure.
Whitespace change from v1
v1 defaulted autoTrim to [false, "nl"] (trim one newline after tags). v2 defaults to [false, false] (preserve all whitespace). Migrated templates may produce extra blank lines where %> previously consumed a trailing newline. Add -%> to the closing side of tags that need the old trim behavior. See Whitespace control.
Expected result
After completing these steps, your custom Eta templates render with v2 field names and helpers. Notes that have been overwritten contain the %%zt-managed%% markers and respond to incremental updates. Notes you have not overwritten continue to work for frontmatter-only refreshes.
Eta to Liquid translations
v2's default engine is Liquid. Converting your templates to Liquid removes the need for the JavaScript Templates gate entirely. For each construct, the table below shows the Liquid equivalent and links to the relevant syntax reference section.
| Eta construct | Liquid equivalent | Reference |
|---|---|---|
<%= zt.title %> | {{ zt.title }} | Outputs and tags |
<% if (zt.abstract) { %> ... <% } %> | {% if zt.abstract %} ... {% endif %} | Control flow |
<% for (const a of zt.annotations) { %> ... <% } %> | {% for a in zt.annotations %} ... {% endfor %} | for / endfor |
<%~ include("content", zt) %> | {% render "content" with zt as zt %} | Includes with render |
<% bq(() => { %> ... <% }) %> | {% bq %} ... {% endbq %} | bq: blockquote block |
embed(zt.imgLink) | {{ zt.imgLink | embed }} | embed filter |
<%= suffix() %> | {% suffix %} | suffix tag |
zt.attachments[0].fileLink() | {{ zt.attachments[0] | file_link }} | Link filters |
zt.collections.map(c => c.path.join(" > ")) | {{ zt.collections | collection_paths: " > " }} | collection_paths |
zt.tags.map(t => t.name).join(", ") | {{ zt.tags | map: "name" | join: ", " }} | Array filters |
zt.dateAdded (Temporal.Instant formatting) | {{ zt.dateAdded | date: "%Y-%m-%d" }} | date filter |
-%> (trim one newline after tag) | -%} / -}} | Whitespace control |
Liquid template files use the .liquid.md extension (e.g. zotlit-note.liquid.md). The data variable is still zt and the property names are identical to the Eta equivalents documented above.