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.
Coming from ZotLit 2.1?
This page covers v1 Eta templates. If you updated from 2.1, where a note's
look was spread across files such as zotlit-note.liquid.md, see Migrate
from 2.1 templates instead.
Two paths exist. Keep Eta: rename the files, update the data vocabulary, and enable the JavaScript templates gate, which the rest of this page covers. Or convert to Liquid: rewrite each template in v2's default Liquid engine, which needs no gate, with 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 reads templates from a zotlit- prefix, replacing zt-, and the template formerly called annots is now content. Rename your files to these names first. ZotLit loads and renders them under these names, and the one-pass conversion below folds them into v2's template documents.
| v1 filename | Rename to | Where its content ends up |
|---|---|---|
zt-note.eta.md | zotlit-note.eta.md | The note body of the default profile document |
zt-annots.eta.md | zotlit-content.eta.md | The managed block inside that same note body |
zt-annot.eta.md | zotlit-annotation.eta.md | The annotation section of that same profile document |
Your two v1 citation files, zt-cite*.eta.md, take the same zt- to zotlit- rename. Both of them fold into one citation text document, zotlit-citation.md, as an if/else on zt.variant.
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 the 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 provides link-only entries. A child note becomes a separate file only when you import it:
| v1 | v2 |
|---|---|
it.notes: array of {title, content} | zt.notes: array of {key, title, noteLink()} |
| Note content inlined | noteLink() links to the imported file, or else opens the note in Zotero |
The default template no longer lists child notes. To list them as links, add a loop like this to your template:
<% 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 annotations alone.
Update the citation source
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).- Both of your v1 citation files render from one v2 document. The conversion below writes them as the two branches of an
if/elseonzt.variant, which is"main"or"alt". See Citation templates.
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 (the first populated role: primary creator, editor, director, then contributor) 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 one-pass conversion later folds it into the filename key of your profile document's manifest. 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 rebuild its fields as managed frontmatter entries in your profile document. Open the document from the Templates and properties row at Settings > ZotLit with Edit profile, then select the Properties tab.
For each field, select Add a property and fill in:
- Property name: the YAML property name.
- Value format: Zotero field · Liquid for a Zotero field, Fixed text for fixed text, or Rule · JSON-e for a JSON-e rule.
- Value: the value. A Zotero field uses a
zt.path, such aszt.title. - When the note is updated: Replace the existing value, Add to the existing list, or Keep the existing value.
| v1 template line | Property name | Value | When the note is updated |
|---|---|---|---|
title: "<%= it.title %>" | title | zt.title | Replace the existing value |
authors: ... | authors | zt.authors | map: "fullName" | join: ", " | Replace the existing value |
year: "<%= it.date %>" | year | zt.date | date: "%Y" | Replace the existing value |
tags: ... | tags | zt.tags | map: "name" | join: ", " | Add to the existing list |
YAML escaping is handled automatically.
The Properties tab writes each property as one entry in the frontmatter list of the profile document manifest. An entry carries a key and one value member, and the member name declares the language: expr holds a Liquid value, which is what Zotero field · Liquid writes; value holds fixed text or a JSON-e rule, which is what Fixed text and Rule · JSON-e write; and js holds a JavaScript value, which you write in Source.
ZotLit manages reserved frontmatter keys that you cannot target from a user field, such as zotero-key, zotlit-profile, zotero-note-key, and zotero-lastmod. The default citekey entry is an ordinary managed frontmatter field. See Frontmatter reference for details.
JavaScript values
A js entry runs only while JavaScript templates is on. See Enable JavaScript templates.
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. Your renamed zotlit-content template becomes the managed region, so ZotLit inserts the managed-region markers.
After overwriting, the note responds to ZotLit: Update literature note for incremental updates. 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.
Convert to template documents
Renamed files load and render as they are, so you can stop here. ZotLit also offers to fold them into v2's template documents in one pass, from the setup banner above the Library View or from a reminder row at the top of settings, and it verifies the output before it writes anything.
Migrate from 2.1 templates covers that conversion: what each file becomes, what the pass checks, and what it refuses.
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 |
A renamed 2.1 Liquid file uses the .liquid.md extension (e.g. zotlit-note.liquid.md); a converted template document carries no language in its name. The data variable is still zt and the property names are identical to the Eta equivalents documented above.
See also
Last updated on