Prerequisites
- macOS (the Zotero connector paths and keybindings assume macOS, but the concepts apply to Linux and Windows with minor changes)
- Neovim 0.9+ with lazy.nvim as your plugin manager
- Homebrew (for installing Zotero)
- Python 3 (for the import script)
- Basic familiarity with the terminal and nvim configuration
Introduction
Citation management is one of the most tedious parts of long-form essay writing. The workflow of copy-pasting URLs, formatting notes by hand, and renumbering footnotes when you insert a reference in the middle of a draft is fragile and slow. This tutorial walks through a better approach: Zotero as your reference library, Better BibTeX to auto-generate citekeys and bibliography files, and the zotcite Neovim plugin to insert [^1] references directly from your editor. The result is a file you can run through pandoc with --citeproc to produce formatted footnotes and bibliography automatically.
By the end of this tutorial, you will have:
- Zotero installed and populated with your references
- Better BibTeX exporting a
.bibfile that updates automatically - zotcite installed in Neovim with working keybindings
- The render pipeline in render-post.py extended to resolve citekeys
- A script to import existing manual
[^N]footnotes from an essay into Zotero - A script to replace
[^N]markers with[^2]patterns
Step 1: Install and Configure Zotero
Install Zotero 7 from the official website or via Homebrew:
brew install --cask zoteroLaunch Zotero. It opens with an empty “My Library” – this is your reference database. Each entry you add becomes a row in a SQLite database located at ~/Zotero/zotero.sqlite.
To add references, you have three options:
- Browser connector: Install the Zotero Connector browser extension (available for Firefox, Chrome, and Brave). When you visit a journal article, blog post, or news story, click the connector icon in your toolbar. Zotero extracts metadata (author, title, journal, DOI, URL) automatically and saves it to your library.
- DOI lookup: In Zotero, click the “Add item by identifier” button (magic wand icon) and paste a DOI, ISBN, or PMID. Zotero fetches the metadata.
- Manual entry: Click the “New Item” button (green plus) and choose the item type. This is necessary for sources without clean metadata (some blog posts, government reports, etc.).
For bulk import of existing footnotes, skip ahead to Step 5, which provides a script.
Verification
Open Zotero and confirm you see items in your library. Close Zotero so the database is not locked, then run:
sqlite3 ~/Zotero/zotero.sqlite "SELECT COUNT(*) FROM items;"You should see a number greater than zero.
Step 2: Install Better BibTeX
Better BibTeX is a Zotero plugin that generates clean, human-readable citekeys and auto-exports your library as a .bib (BibTeX) file.
- In Zotero, go to Tools -> Add-ons
- Click the gear icon -> Install Add-on From File
- Alternatively, download the latest release from https://retorque.re/zotero-better-bibtex/ and drag the
.xpifile into Zotero’s Add-ons window - Restart Zotero
Configure Auto-Export
- In Zotero, select your library
- File -> Export Library
- Format: Better BibTeX
- Check Keep updated (this is critical – it enables automatic re-export whenever your library changes)
- Save to
~/Zotero/bibliography.bib
You can verify the export path in Zotero’s preferences under Better BibTeX -> Automatic export – you should see an entry for the file you just created.
Verification
Check that your .bib file exists and contains entries:
head -5 ~/Zotero/bibliography.bib
grep '^@' ~/Zotero/bibliography.bib | wc -lYou should see entries starting with [^3]{, [^4]{, [^5]{, etc., using citekeys like [^6]. The @ prefix in the first line of each entry is the citekey you will use in your documents.
Step 3: Install and Configure zotcite in Neovim
zotcite is a Lua plugin for Neovim that reads your Zotero database (or .bib file) and lets you search and insert citations from within your editor.
Add the following to your Neovim plugin configuration. If you use lazy.nvim, add this to your plugin spec list (usually in ~/.config/nvim/init.lua within the require("lazy").setup({...}) block):
{
"jalvesaq/zotcite",
ft = { "markdown", "pandoc" },
opts = {
key_type = "better-bibtex",
zotero_sqlite_path = vim.fn.expand("$HOME") .. "/Zotero/zotero.sqlite",
},
init = function()
-- ftplugin handles keymaps automatically
end,
},Reload lazy.nvim by restarting Neovim, or run:
nvim --headless "+Lazy sync" +qaUnderstanding the Keybindings
zotcite registers the following keymaps in .md and .pandoc files:
| Key | Mode | Action |
|---|---|---|
<C-X><C-B> |
Insert | Search Zotero and insert a [^7] |
<leader>zi |
Normal | Show citation info under cursor |
<leader>zo |
Normal | Open the Zotero attachment under cursor |
<leader>zv |
Normal | View document |
Verification
Open a markdown file in Neovim, switch to insert mode, and press <C-X><C-B>. A completion menu should appear showing your Zotero items. Type a few letters to narrow the search. Use <C-N> and <C-P> to navigate, and <C-Y> to select and insert a [^8].
Step 4: Extend the Render Pipeline
Once your markdown contains [^9] references, you need a renderer that resolves them into formatted footnotes. The following Python function reads your .bib file, scans the markdown for [^10] patterns, replaces each with a [^N] marker, and appends [^N]: footnote entries to the end of the body.
import re
from pathlib import Path
BIB_FILE = Path.home() / 'Zotero' / 'bibliography.bib'
def resolve_citations(body_md: str) -> str:
if not BIB_FILE.exists():
return body_md
bib_text = BIB_FILE.read_text(encoding='utf-8')
entries = {}
# Parse each [^11]{citekey, ...}
for m in re.finditer(r'@\w+\s*\{\s*([^,]+)\s*,', bib_text):
citekey = m.group(1).strip()
start = m.end()
depth = 1
pos = start
while pos < len(bib_text) and depth > 0:
if bib_text[pos] == '{': depth += 1
elif bib_text[pos] == '}': depth -= 1
pos += 1
entry_body = bib_text[start:pos - 1] if depth == 0 else bib_text[start:]
fields = {}
for line in entry_body.split('\n'):
fm = re.match(r'^\s*(\w[\w-]*)\s*=\s*\{', line)
if fm:
fname = fm.group(1)
fstart = fm.end()
fdepth = 1
fpos = fstart
while fpos < len(line) and fdepth > 0:
if line[fpos] == '{': fdepth += 1
elif line[fpos] == '}': fdepth -= 1
fpos += 1
fval = line[fstart:fpos - 1].strip() if fdepth == 0 else line[fstart:].strip()
if fval:
fields[fname.lower()] = fval
author = fields.get('author', '').replace('{', '').replace('}', '')
title = fields.get('title', '').replace('{', '').replace('}', '')
year = fields.get('year', '')
journal = (fields.get('journal', '') or fields.get('booktitle', '') or '').replace('{', '').replace('}', '')
url = fields.get('url', '')
parts = []
if author: parts.append(author)
if title: parts.append(f'"{title}"')
if journal: parts.append(f'*{journal}*')
if year: parts.append(f'({year})')
ref = ', '.join(parts)
if url: ref += f', {url}'
entries[citekey] = ref
if not entries:
return body_md
collected = []
counter = [0]
def _replace(m):
counter[0] += 1
n = counter[0]
inner = m.group(1) or m.group(2) or ''
keys = re.findall(r'@(\S+)', inner)
if not keys:
keys = [inner.strip()]
refs = []
sep = "; "
for k in keys:
k = k.rstrip(',;. \t')
refs.append(entries.get(k, f'{k} (not found)'))
collected.append(f'[^{n}]: {sep.join(refs)}')
return f'[^{n}]'
body_md = re.sub(r'\[^12]]+)\]|(?<!\w)@(\w[\w-]*)(?!\w)', _replace, body_md)
if collected:
body_md += '\n\n---\n\n## Notes\n\n'
body_md += '\n\n'.join(collected)
return body_mdIntegrate this function into your existing render script by calling it on the markdown body before passing it to pandoc. For example, if your render script does:
body_html = markdown_to_html(body_md)Change that to:
body_html = markdown_to_html(resolve_citations(body_md))Verification
Create a test markdown file with a citekey:
Test citation: [^13]Run your render script on it. The output HTML should contain a superscript footnote link pointing to a footnote entry at the bottom of the page with the formatted reference.
Step 5: Import Existing Footnotes into Zotero
If you have an essay with manually written [^N]: footnotes, you can import them into Zotero programmatically using Zotero’s local connector API. The script below parses your footnotes, converts them to CSL-JSON, posts them to Zotero via the API, and writes a mapping file for the next step.
Save the following as ~/av/bin/footnote-to-zotero.py:
#!/usr/bin/env python3
"""Parse [^N]: footnotes from an archerships essay and import them into Zotero
via its local connector API.
Usage:
python3 footnote-to-zotero.py path/to/essay.md
"""
import json, re, requests
from pathlib import Path
ZOTERO_API = 'http://127.0.0.1:23119/connector/saveItems'
OUTPUT_DIR = Path.home() / 'Zotero' / 'locate'
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
TYPE_MAP = {
'article-journal': 'journalArticle', 'book': 'book',
'report': 'report', 'webpage': 'webpage',
}
def parse_footnotes(md_path):
text = md_path.read_text(encoding='utf-8')
if text.startswith('---'):
parts = text.split('---', 2)
text = parts[2] if len(parts) >= 3 else text
footnotes = []
for m in re.finditer(r'\[\^(\d+)\]:\s*(.*?)(?=\n\[\^\d+\]:|\Z)', text, re.DOTALL):
footnotes.append((int(m.group(1)), m.group(2).strip()))
return sorted(footnotes, key=lambda x: x[0])
def extract_url(body):
m = re.search(r'https?://\S+', body)
return m.group(0).rstrip('.,;:)') if m else ''
def extract_year(body):
m = re.search(r'\b(19\d{2}|20\d{2})\b', body)
return m.group(1) if m else ''
def extract_title(body):
m = re.search(r'"(.+?)"', body)
return m.group(1) if m else ''
def detect_type(body):
bl = body.lower()
if re.search(r'\(.*(?:Press|University).*\)', body): return 'book'
if any(x in bl for x in ['working paper','nber','brief']): return 'report'
if any(x in bl for x in ['journal of','law review','review']): return 'article-journal'
return 'webpage'
def to_zotero_item(fn_num, body):
url = extract_url(body)
title = extract_title(body)
year = extract_year(body)
etype = detect_type(body)
ztype = TYPE_MAP.get(etype, 'webpage')
author_text = re.match(r'^([^,"]+?)(?:,| "|\.)', body)
author_text = author_text.group(1).strip() if author_text else ''
creators = []
for part in re.split(r'\s+and\s+', re.sub(r'\bet al\.?', '', author_text).strip(',; ')):
if not part: continue
if ',' in part:
last, first = part.split(',', 1)
creators.append({"creatorType":"author","lastName":last.strip(),"firstName":first.strip()})
else:
creators.append({"creatorType":"author","name":part})
if not creators:
creators.append({"creatorType":"author","name":author_text or "Unknown"})
item = {"itemType": ztype, "title": title, "creators": creators, "url": url}
if year: item["date"] = str(year)
if ztype == 'journalArticle':
m = re.search(r'\*([^*]+)\*', body)
if m: item["publicationTitle"] = m.group(1)
elif ztype in ('book', 'report'):
m = re.search(r'\(([^)]+(?:Press|University|Institute|Center)[^)]*)\)', body)
if m: item["publisher"] = m.group(1)
return item
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument('md_file')
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
md_path = Path(args.md_file).resolve()
footnotes = parse_footnotes(md_path)
print(f'Found {len(footnotes)} footnotes.')
items = [to_zotero_item(num, body) for num, body in footnotes]
for num, body in footnotes:
print(f' [{num:2d}] {extract_title(re.sub(r"\\s+"," ",body).strip())[:50]}')
if args.dry_run:
print(f'\nDry run: would import {len(items)} items.')
return
ok = err = 0
for i in range(0, len(items), 10):
batch = items[i:i+10]
try:
r = requests.post(ZOTERO_API, json={'items': batch}, timeout=10)
if r.status_code == 201: ok += len(batch)
else: err += len(batch)
except Exception as e:
err += len(batch)
print(f'\nImported: {ok}, errors: {err}')
# Write mapping template
slug = md_path.parent.name
mapping_path = OUTPUT_DIR / f'{slug}-footnotes.mapping.txt'
with open(mapping_path, 'w') as f:
for num, body in footnotes:
f.write(f'[^{num}] -> # {re.sub("\\s+"," ",body)[:80]}\n')
print(f'Mapping template: {mapping_path}')
if __name__ == '__main__':
main()Run it:
python3 footnote-to-zotero.py path/to/essay.mdZotero must be running and the Zotero Connector browser extension must be installed for the API to be available. The script posts items in batches of 10 to the local API at http://127.0.0.1:23119/connector/saveItems.
Step 6: Map [^N] Markers to 1
After the import completes, Better BibTeX auto-exports your .bib file with citekeys. The script below reads the .bib file and the essay, then replaces each [^N] with a [^15] by matching the footnote text against the .bib entries. It also removes the Notes section.
Save the following as ~/av/bin/replace-footnotes-with-citekeys.py:
#!/usr/bin/env python3
"""Replace [^N] markers with [^16] in an archerships essay.
Reads the essay's [^N] footnotes, matches them against entries in the
Zotero .bib file by comparing text fields, and rewrites the essay with
[^17] references. Removes the Notes section after replacement.
Usage:
python3 replace-footnotes-with-citekeys.py path/to/essay.md [--dry-run]
"""
import re, argparse
from pathlib import Path
from difflib import SequenceMatcher
BIB_FILE = Path.home() / 'Zotero' / 'bibliography.bib'
def parse_bib_entries():
"""Return {citekey: {field: value}} from the .bib file."""
if not BIB_FILE.exists():
print(f'Error: {BIB_FILE} not found. Run the Zotero import first.')
return {}
bib_text = BIB_FILE.read_text(encoding='utf-8')
entries = {}
for m in re.finditer(r'@(\w+)\s*\{\s*([^,]+)\s*,', bib_text):
entry_type = m.group(1)
citekey = m.group(2).strip()
start = m.end()
depth = 1
pos = start
while pos < len(bib_text) and depth > 0:
if bib_text[pos] == '{': depth += 1
elif bib_text[pos] == '}': depth -= 1
pos += 1
entry_body = bib_text[start:pos - 1] if depth == 0 else bib_text[start:]
fields = {}
for line in entry_body.split('\n'):
fm = re.match(r'^\s*(\w[\w-]*)\s*=\s*\{', line)
if fm:
fname = fm.group(1).lower()
fstart = fm.end()
fdepth = 1
fpos = fstart
while fpos < len(line) and fdepth > 0:
if line[fpos] == '{': fdepth += 1
elif line[fpos] == '}': fdepth -= 1
fpos += 1
fval = line[fstart:fpos - 1].strip() if fdepth == 0 else line[fstart:].strip()
if fval:
fields[fname] = fval.replace('{', '').replace('}', '')
fields['_citekey'] = citekey
fields['_type'] = entry_type
entries[citekey] = fields
return entries
def parse_footnotes(text):
"""Return [(number, body_text)] from the essay markdown."""
footnotes = []
for m in re.finditer(r'\[\^(\d+)\]:\s*(.*?)(?=\n\[\^\d+\]:|\n---|\Z)', text, re.DOTALL):
footnotes.append((int(m.group(1)), m.group(2).strip()))
return sorted(footnotes, key=lambda x: x[0])
def score_match(fn_body: str, fields: dict) -> float:
"""Score how well a footnote body matches a bib entry (0 to 1)."""
scores = []
fn_lower = fn_body.lower()
# Title match (most reliable)
bib_title = fields.get('title', '').lower()
if bib_title:
# Check how much of the title appears in the footnote
ratio = SequenceMatcher(None, bib_title, fn_lower).ratio()
scores.append(ratio * 3) # title heavily weighted
# Author match
bib_author = fields.get('author', '').lower()
if bib_author:
# Extract first author surname
auth_match = re.match(r'([^,]+)', bib_author)
if auth_match:
surname = auth_match.group(1).strip()
if surname in fn_lower:
scores.append(2.0)
else:
scores.append(0.0)
# Journal/publisher match
for fname in ('journal', 'booktitle', 'publisher'):
val = fields.get(fname, '').lower()
if val and val[:30] in fn_lower:
scores.append(1.5)
# URL match (definitive)
bib_url = fields.get('url', '').lower().rstrip('/')
if bib_url and bib_url in fn_lower:
return 10.0 # URL match is conclusive
if not scores:
return 0.0
return sum(scores) / len(scores)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('md_file', help='Path to the essay .md file')
ap.add_argument('--dry-run', action='store_true',
help='Show what would change without modifying the file')
args = ap.parse_args()
md_path = Path(args.md_file).resolve()
if not md_path.exists():
ap.error(f'File not found: {md_path}')
# Parse bib
entries = parse_bib_entries()
if not entries:
return
# Read essay
text = md_path.read_text(encoding='utf-8')
# Split frontmatter
fm_sep = ''
body = text
if text.startswith('---'):
parts = text.split('---', 2)
if len(parts) >= 3:
fm_sep = '---'
body = parts[2]
# Parse footnotes from the body
footnotes = parse_footnotes(body)
if not footnotes:
print('No [^N]: footnotes found.')
return
print(f'Found {len(footnotes)} footnotes to map.')
# Match each footnote to a bib entry
# First pass: URL match (exact)
fn_to_citekey = {}
unmatched = []
for num, fn_body in footnotes:
fn_url = ''
url_m = re.search(r'https?://\S+', fn_body)
if url_m:
fn_url = url_m.group(0).rstrip('.,;:)')
best_key = None
best_score = 0
for ck, fields in entries.items():
s = score_match(fn_body, fields)
if s > best_score:
best_score = s
best_key = ck
if best_key and best_score >= 2.0:
fn_to_citekey[num] = best_key
else:
unmatched.append((num, fn_body, best_key, best_score))
# Report
matched_count = len(fn_to_citekey)
print(f'Matched: {matched_count}, unmatched: {len(unmatched)}')
for num, body, best, score in unmatched:
fn_title = re.match(r'^[^,]+', body)
fn_title = fn_title.group(0) if fn_title else body[:40]
best_str = best or '(none)'
print(f' [?] [{num:2d}] score={score:.1f} best={best_str} -> {fn_title[:50]}')
if args.dry_run:
return
# Replace [^N] in body with [^18]
# Order by descending N so replacements don't shift later references
for num in sorted(fn_to_citekey.keys(), reverse=True):
citekey = fn_to_citekey[num]
# Replace [^num] with [^19]
body = re.sub(
rf'\[\^{num}\]',
f'[^20]',
body
)
# Remove the Notes section
body = re.sub(r'\n---\n\n## Notes\n\n.*?(\n---|\Z)', r'\1', body, flags=re.DOTALL)
body = body.strip() + '\n'
# Write back
new_text = fm_sep + '\n' + body if fm_sep else body
md_path.write_text(new_text, encoding='utf-8')
print(f'\nWritten to {md_path}')
print(f'Replaced {matched_count} footnote markers with [^21] references.')
if __name__ == '__main__':
main()Run it:
python3 replace-footnotes-with-citekeys.py path/to/essay.md [--dry-run]Use --dry-run first to see which footnotes matched and which need manual attention. The script uses a scoring system: a URL match is conclusive (score 10.0), title and author matches contribute to a lower threshold. Footnotes below the threshold are listed as unmatched for manual review.
Verification
After running the mapping script, open the essay in Neovim. You should see [^22] patterns throughout the text instead of [^N] markers. Place your cursor on one and press <leader>zi – zotcite should display the citation info. Press <leader>zo to open the original source if Zotero has an attachment.
Run the render script:
python3 render-post.py path/to/essay.mdOpen the resulting HTML in a browser. The footnote links should point to correctly formatted references at the bottom of the page.
Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
| Zotero API returns 404 | Zotero Connector not installed | Install the browser extension, make sure it is active |
[^N] markers still in output |
resolve_citations() not called |
Check that your render script invokes it before pandoc |
citekey not found in .bib |
Better BibTeX auto-export not running | Re-export with “Keep updated” checked |
<C-X><C-B> does nothing in nvim |
File not recognized as markdown | Run :set ft=markdown in the buffer |
Footnote text has {{ curly braces |
Better BibTeX uses braces for case preservation | Strip { and } from the resolved citation text |
Could not find 'bibliography' field in YAML header on save |
zotcite needs the field in file frontmatter | Add bibliography: ~/Zotero/bibliography.bib to the essay’s YAML frontmatter |
HTML renders --- sections as narrow <table> instead of <hr> |
Pandoc 3.x interprets --- followed by text without a blank line as a simple table |
Always leave a blank line after --- thematic breaks, or use markdown+raw_html-yaml_metadata_block as the pandoc format |
pandoc: YAML parse exception with --- in the body |
Pandoc 3.x tries to parse YAML metadata blocks mid-document | Use --from markdown+raw_html-yaml_metadata_block in the pandoc command to disable YAML parsing |
Notes
The scripts above are available at ~/av/bin/footnote-to-zotero.py and ~/av/bin/replace-footnotes-with-citekeys.py. The extended render script is at ~/av/bin/archerships/render-post.py.
Notes
citekey (not found in bibliography)↩︎
Want to stay in touch?
- Signal (announcements): https://signal.group/#CjQKIGLn7xDB0uOXMMlbKlsKEG0CmkmL9gk3U0SeIX0KlKRZEhDoqIluCXo84TrBz-2tMJD7
- Signal (discussion): https://signal.group/#CjQKIDA0v6tUciWe-3jRArkbYttju8xfuoczTOfMrGuvhmEZEhCrOnPk-IWFmFmipdI1EHxv
- Signal: archerships.43 (https://signal.me/#eu/9JUc8x9c-QA0_-QR9qQd0HUmjsnAG1BeOJM2nDo5DopjIPq5bThAJYr99lsh0cPP)
- Mailing list: https://archerships.substack.com/subscribe
- Email: [email protected]
- Website: https://archerships.com
- Substack: https://substack.com/@archerships
- Twitter: https://x.com/archerships
- Facebook: https://www.facebook.com/archerships
- Yahihonne: https://yakihonne.com/profile/nprofile1qqsgr0xn6vvr8su9ptzj4n50j8vzmczzayed0wcl5rdnvh0tc6xhqncy6jrjw
- Nostr-npub:
npub1sx7d85ccx0pc2zk99t8glywc9hsy96fj67a3lgxmxew7h35dwp8shak49e - Odysee: https://odysee.com/@archerships:6
- TikTok: https://www.tiktok.com/@archertships
Support my work
- Donations (crypto): https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=85e4n5bgLTWiAWZbkjbbF5MLrwyiU8kjxHWHL9t6vDE5MyNUCPzBuZUNDcvbCisC5iW5PPBP9ETRQUWQQjMuvAhHRFaYCeM&donation=True&simple_mode=True&name=Archerships&[email protected]&ticker_from=xmr&network_from=Mainnet&bgcolor=000000ff
- Donations (fiat): https://ko-fi.com/archerships
- Consulting: privacy / crypto / censorship consulting – email or Signal