I moved my notes from Obsidian to Trilium in August, and lost the one thing I used every day: a button that saves the page I am reading. Trilium has an official clipper for Chrome and Firefox. I use Safari. So I wrote one.

Markdown is a detour
Almost every web clipper converts the page to Markdown on the way in. It sounds tidy, and for a plain essay it mostly is. The trouble starts with everything that is not a paragraph: tables lose their columns, figure captions come loose and drift to the end of the note, footnotes turn into stray links.
What makes it worse is where the note is going. Trilium stores text notes as HTML. So the Markdown you carefully produced gets rendered straight back to HTML by the editor. You paid for a round trip and arrived where you started, minus the parts that did not survive it.
Tri Clipper keeps the HTML. It runs Mozilla’s Readability to find the article, then strips what is left down to the tag set Trilium’s editor actually keeps — paragraphs, headings, lists, tables, figures, links, images, emphasis. Everything else is dropped, including the scripts, the styles and the class names that Trilium would have thrown away later anyway.
The dull part of that job turns out to be the important one: resolving lazy-loaded images to their real source instead of the 1×1 placeholder in src, making relative links absolute so they still work in a year, and picking the widest candidate out of a srcset rather than whichever one happened to fit the window when you clicked.
A template decides four things

Where the note goes, what it is called, which labels it carries, and what its body looks like. That is the whole model. Titles and labels are written with variables — the page title, the author, the publication date, any meta tag, any JSON-LD field, or the text of any element you name with a CSS selector. Filters shape them on the way through:
{{title|safe}}
{{author|split:","|first}}
{{published|date:"YYYY-MM-DD"}}
{{meta:og:site_name}}
{{selector:.byline|trim}}
Where Obsidian’s clipper writes YAML frontmatter, this one writes Trilium labels. Same idea, native to the destination: #author, #published, #site, and a #clipUrl holding the address the note came from.
That last label does more work than it looks. Give a template a list of sites and it claims them, so clipping from a particular blog picks the template you wrote for that blog without you choosing it.
Highlights, and clipping the same page twice

Turn on highlight mode, mark the passages you want, and the note contains only those. Come back to the same page a week later, highlight something else, and the new passages join the note you already made. That is what #clipUrl is for: the extension searches for a note carrying this page’s address and appends to it rather than starting a second one.
You see the note before it is written

The popup shows the note that is about to be created, with every variable already resolved. Change the title, drop a label, pick a different destination, and that is what Trilium receives. I built this after the third time I discovered a badly-titled note two days later.
The extension does not talk to the server
This is the part I did not expect to spend an evening on.
The obvious design is for the extension to call Trilium’s ETAPI directly. Two WebKit rules get in the way. Trilium sends no CORS headers at all, so a cross-origin request from the extension is refused. And a Trilium on your own network is reached over plain http, which counts as mixed content from the extension’s own secure origin.
Neither restriction applies to URLSession in a native app. So the JavaScript side does not make the request: it hands it through native messaging to a small Swift handler that ships inside the extension, and that handler performs it. The relay accepts only paths under /etapi/, resolved against the address in your settings, which keeps it from becoming a general-purpose proxy for anything that reaches the extension.

There is a switch for the direct route, for anyone whose Trilium already sits behind https with CORS opened up. The relay is the default because it works without asking anything of your server.
Built without Xcode, at first
When I started there was no Xcode on this Mac, only the Command Line Tools. I assumed that ruled out a Safari extension, since Safari only loads extensions that ship inside an app bundle and safari-web-extension-converter is an Xcode tool.
It turns out an .appex is an ordinary bundle you can assemble by hand. The one thing Xcode does that is hard to guess at is a single linker flag: app extensions are entered through NSExtensionMain in Foundation rather than through main().
swiftc -O -module-name TriClipperExtension -parse-as-library \
-Xlinker -e -Xlinker _NSExtensionMain \
-framework SafariServices \
-o "$APPEX/Contents/MacOS/TriClipperExtension" \
Sources/Handler/SafariWebExtensionHandler.swift
Add an Info.plist naming com.apple.Safari.web-extension as the extension point, drop the web extension’s files into the appex’s Resources, sign both bundles, and macOS registers it. One detail costs an hour if you miss it: the containing app has to be launched once before pluginkit picks the extension up. Registering it without running the app is not enough.
The catch is signing. An ad-hoc signature means Safari will only load the extension while Allow unsigned extensions is ticked in the Develop menu, and that setting turns itself off every time Safari quits. A Developer ID certificate is what makes that go away, which is the reason this post exists at all — the app is on its way to the App Store.
In the end I installed Xcode anyway. The iOS SDK and the simulator only come with it, and a build for iPhone and iPad needs both. It also takes care of signing: xcodebuild -allowProvisioningUpdates creates the bundle identifiers and provisioning profiles for all four targets, work that otherwise happens by hand in the developer portal.
The project comes from safari-web-extension-converter, run without --copy-resources, so it points at the same extension folder the hand-built version uses. There is one copy of the web extension and nothing to keep in sync. The converter did leave mistakes behind. The worst: it allowed outgoing connections for the app and forgot the extension, which is where the relay runs. The app would have looked healthy while every clip failed. A script that builds both ways and compares the results is what caught it.
The hand-built version still exists, but releases come from Xcode because it also builds for Intel Macs.
Where to get it
Tri Clipper is coming to the App Store shortly, for Mac, iPhone and iPad in a single purchase. It needs a Trilium server with ETAPI enabled, and a token you make under Options → ETAPI. A server on your own network can be plain http; one you reach from elsewhere has to be https.
It is not affiliated with the Trilium project. It is a thing I wanted on my own machine, and it turned out to be worth finishing properly.