Building and testing

Building the plugin and this documentation, the repository layout, the test suite and what CI checks on each platform.

Build

git clone https://github.com/fre-ms/zotLook.git
cd zotLook
./build.sh

build.sh compiles the helper binary (qlpreview) for both architectures and signs them — swiftc ad-hoc signs the arm64 slice but not the x86_64 one, and lipo does not sign the container, so without this the result reports as unsigned. It then packages the .xpi and rewrites update.json to match: version, download URL and SHA-256 all derived from the manifest and the file that was just built.

A release build therefore needs macOS with the Xcode Command Line Tools. That helper is the only compiled piece in the package: the Windows route drives QuickLook’s pipe through js-ctypes and ships no binary of its own.

The same script also runs on Linux, where there is no Swift toolchain. It packages everything else, which is all the Linux route needs, and leaves update.json alone — a package without the helper is fine to install and test locally, but publishing its checksum would auto-update macOS copies to a build that has lost their Quick Look panel.

The development loop

Building an XPI to look at a one-line change is slower than it needs to be, and it hides a trap: when the version number has not changed, Zotero keeps the copy it already has, so the first restart shows the old plugin and only the second shows the new one. That has cost more time than any bug.

Zotero can install a plugin from a directory instead — a file in the profile’s extensions/ named after the plugin id, holding the path to it:

node tool/dev.mjs            # install from the tree, restart, then watch
node tool/dev.mjs --install  # only install
node tool/dev.mjs --restore  # put the packaged copy back

Nothing is packaged and nothing is copied: a restart picks up whatever is in addon/ at that moment. On macOS the install first builds the Quick Look helper into addon/ (build.sh --helper), because build.sh removes it once the XPI holds it and a tree without it falls back to qlmanage — whose panel neither hands the contact sheet’s links to the system nor closes on the handoff. The preview then looks right and the links do nothing. The watch restarts Zotero when a file there changes, with --purgecaches, which matters — Zotero caches compiled scripts across starts, and bootstrap.js is cached by Zotero rather than by the plugin, so without it a restart can run yesterday’s loader against today’s modules.

The packaged copy is set aside rather than deleted — in the profile root, not in extensions/, which Zotero owns and sweeps clear of anything it does not recognise — and --restore puts it back, so the profile ends up exactly as it was. It also clears extensions.lastAppBuildId, without which none of this works at all: Zotero rescans extensions/ only when the build it recorded no longer matches, so a proxy file beside an entry that still names the package is never looked at. The script also re-enables the plugin in extensions.json: one that was disabled once stays disabled however it is installed, which makes a correct install look like a broken one.

--install and --restore both act on files Zotero reads at startup, and on a preference it rewrites as it exits, so close Zotero before either — under a running one they look like they worked and change nothing. The watch does not have this problem: it closes Zotero itself. And while a development install is in place, any start loads the working tree, including one from the dock or the start menu; --restore is what puts the packaged copy back.

It takes --profile for a profile other than the one profiles.ini names, and reads ZOTERO_BIN for a Zotero somewhere unusual, ZOTERO_QUIT for a way of closing it other than the platform’s own, and ZOTERO_PROBE for a command that exits 0 while it runs. The restart waits for that to say gone before it starts anything: open -a on a Zotero still closing only activates it and cancels the quit — which is also what lets test/dev.test.mjs drive the whole loop against stubs, rather than closing the Zotero of whoever runs the suite.

It is not hot reloading, and that was a decision rather than an omission. Reloading a plugin inside a running Zotero needs Gecko’s remote debugging protocol, or a plugin that registers an endpoint for it — which is what zotero-plugin-scaffold uses, through a zotero://ztoolkit-debug handler that comes from zotero-plugin-toolkit and not from Zotero. Zotero’s own zotero:// endpoints are attachment, data, report, select, debug, pdf.js and open-pdf; there is no bridge among them. Adopting the scaffold for the sake of the restart would mean sixteen further dependencies against a packaging step that takes six seconds, of which the Swift compiler is under two.

Repository layout

addon/     copied verbatim into the XPI; the plugin as Zotero sees it
native/    Swift helpers, compiled by build.sh rather than shipped
asset/     images
doc/       this documentation
test/      test suite
build/     the packaged .xpi; git-ignored

Zotero requires manifest.json, bootstrap.js and locale/ at the root of the XPI, which is why addon/ is packaged from its inside rather than as a folder.

update.json stays at the repository root rather than moving into build/: it is not a build artefact but a published file: the documentation deploy puts it on zotlook.org, where copies from 1.4.2 on poll it, and older copies poll it on main.

Tests

npm install   # linkedom, to stand in for Gecko's DOM, and eslint
npm test      # lints first, then runs the suites
ImportantThe lint step is not decoration

A lost function parameter leaves an identifier that still parses but throws at runtime. node --check cannot see it, and every suite missed it for as long as the function around it was stubbed.

The suites run the plugin’s own source in a scope with Zotero’s sandbox globals stubbed, so the parsing, sanitising, keyboard, menu and EPUB code can be exercised without launching Zotero. Rendering is driven through a stand-in worker that speaks the same two-step protocol the real one does.

They also check the things that otherwise fail silently: that update.json agrees with the manifest, that both locale bundles define exactly the strings the code asks for, and that the renderer’s source still carries every requirement pdf.js imposes on it — each of which was found by watching it fail, and none of which announces itself when it is missing.

The plugin has no runtime dependencies; linkedom is a development dependency only and is never packaged.

CI

Runs on every push and pull request.

The suites run on Linux, where they are platform-independent, and on Windows, where a pathname is not a path and the assumptions differ. The release build runs on macOS, because swiftc, lipo and codesign live only there; it checks that the XPI holds every file Zotero needs at its root and that the helper is universal and signed. The suites run a second time on that job, where a freshly built XPI is present, so the check that update.json describes the file that was actually produced also runs.

The XPI is not byte-reproducible — zip records modification times — so CI does not compare its checksum against the committed update.json. What it does compare is the version, which is what actually goes stale.

This documentation

cd doc && ./build.sh
python3 doc/offline/smoketest.py site/en site/de

Both language projects render into site/{en,de}, the offline repairs are applied, and one linked PDF per language is built with Typst.

The site is built on the easyQDA-quarto-theme, which is vendored into doc/_theme/ so the documentation builds without a sibling checkout. doc/update-theme.sh pulls a newer copy in.

Quarto is pinned, and the pin is the one the other ecosystem sites use, so they all render on the same version. Upgrades are deliberate: build with ALLOW_UNPINNED_QUARTO=1, run the smoke test, look at a page, then move the pin.

The sidebars of the two languages must stay parallel — gen_langmap.py derives the language switcher’s page-to-page map from them, and fails the build if they stop matching.