Part 1 of 3 · Building a Safari extension in ClojureScript

From ClojureScript to a loadable extension folder

You write ClojureScript; the browser runs JavaScript. This is how shadow-cljs turns the source into the folder a browser could load — and Part 2 is why Safari, alone, won’t.

shadow-cljs, the build

The ClojureScript build tool that produces the JavaScript a browser runs.

We wrote the extension in ClojureScript, but no browser runs ClojureScript. Something has to compile it. That something is shadow-cljs: the ClojureScript build tool. It reads our namespaces, hands them to the Google Closure compiler — the same compiler the ClojureScript toolchain has always used to turn CLJS into JavaScript — and emits JavaScript a browser can load. Closure also does the heavy lifting of dead-code elimination and renaming, so a release build ships only the code each entry point actually reaches.

The whole build is described in one file, shadow-cljs.edn. EDN is ClojureScript’s own data notation, so the build config is just a map — the same language you author the extension in. shadow-cljs.edn:1–18 A single :source-paths vector lists every folder of source the compiler may see; a :builds map then names four distinct builds over that shared source. Two of those builds produce the browser JavaScript the extension ships; the other two are how we develop and test it. Each build is its own compilation with its own target — not four copies of the code, but four ways of slicing the one source tree.

:background · :content
Both :target :browser. These compile to the two JavaScript files the extension actually loads in the browser.
:test
:target :node-test, run on :js-runtime :bun — the unit-test suite, compiled to out/test.cjs and run outside any browser. shadow-cljs.edn:4–8
:dev
:target :node-script on Bun — the REPL runtime we drive development from (bun out/dev.js starts it). shadow-cljs.edn:9–12

So one config, one source tree, four builds: two that the browser will run and two that never leave the terminal. The rest of this page follows the two browser builds, because they are what becomes the loadable extension folder.

How a namespace becomes a bundle

A :target :browser build, an :init-fn entry point, and one JavaScript file out.

Take the :background build. It declares :target :browser and a module: :modules {:background {:init-fn organon.ext.background/init}}. shadow-cljs.edn:13–18 That :init-fn is the entry point. shadow-cljs compiles the graph of namespaces reachable from organon.ext.background, writes the result to public/js/background.js (the build’s :output-dir is public/js, and the module is named :background), and arranges for init to be called when that file loads. The :content build does the same with organon.ext.content/init, producing public/js/content.js.

Two browser builds means two entry points, and that is not an accident: an extension needs exactly two pieces of code running in different worlds — a background service worker and a content script. Why the split exists, and what each world can and can’t see, is the two-worlds rule from the conceptual series: Anatomy of a web extension · The parts. The manifest is what wires each compiled file to its role: "background": { "service_worker": "js/background.js" } and a content_scripts entry pointing at js/content.js. manifest.json:8–11 The build produces the JavaScript; the manifest tells the browser which file is which kind of code.

The shared fact-gate, compiled in

One .cljc validity gate, pulled into both bundles by a source path.

Look again at the source paths: :source-paths ["../common/src" "../common/test" "src" "test" "dev"]. shadow-cljs.edn:1 The first entry reaches up out of the extension folder into a sibling common/. That folder holds the project’s one .cljc fact-validity gate — valid-fact? — and because it is on the source path, the Closure compiler pulls it into both browser bundles. It is the very same code the JVM and Babashka sides of the project run; the extension does not get a second, drifting copy. A fact the extension builds is checked by the identical predicate the receiver will later check it with.

Beyond that shared source, the build leans on almost nothing. The only declared dependency is transit-cljs. shadow-cljs.edn:2 Transit is the wire format the background worker uses to serialise a bundle of facts before it POSTs them to the receiver — the one extra library the flush needs, and the whole of the dependency list.

Each artefact in public/

The unpacked extension — the folder a browser would load directly.

public/ is the unpacked extension: the complete folder a Chromium browser would load as-is, and — in Part 2 — the input the Safari converter is pointed at. Most of what it contains is not compiled at all. Click through the map to see each artefact, what it is, and crucially where it comes from — emitted by shadow-cljs, or a static file we author by hand.

 
manifest.json
The hand-authored Manifest V3 file. Declares the entry points, the permissions, the toolbar action, and the icons — the browser reads it first and nothing runs until it does. manifest.json:1–14
js/background.js
The compiled :background bundle — the service-worker code, emitted by shadow-cljs from organon.ext.background. shadow-cljs.edn:13–15
js/content.js
The compiled :content bundle — the content-script code, emitted from organon.ext.content. shadow-cljs.edn:16–18
js/cljs-runtime/
A directory of 135 per-namespace JavaScript files. This is shadow’s development output: a dev build of background.js is a ~1.9 MB loader that pulls these files in at runtime (CLOSURE_BASE_PATH = '/js/cljs-runtime/'). A release build (bb release) instead emits a single self-contained background.js of ~217 KB with no reference to cljs-runtime/ at all — Closure has optimised the runtime into the one file. The cljs-runtime/ directory simply lingers on disk from the last dev compile; the shipped release bundle does not use it. bb.edn:13–14
js/manifest.edn
shadow’s own build manifest — a record of the module graph (entries, output names, the list of source files that went into each bundle). Note the trap: this is not the web extension’s manifest.json. The folder carries two manifests with the same word and entirely different jobs — one is shadow telling itself what it built, the other is the browser’s entry-point declaration. public/js/manifest.edn:1
popup.html
A hand-authored HTML/CSS file — not compiled from ClojureScript. It is the toolbar popup, and it carries the first-run instruction to grant 127.0.0.1 access so the localhost flush can get through. That grant is the heart of Part 3. popup.html:8–10
icons/icon-128.png
A static image — the toolbar icon the manifest references. Authored, not compiled. manifest.json:13
What shadow makes vs what you author

Of everything in public/, only the two js/*.js bundles come out of shadow-cljs. The web manifest.json, the popup.html, and the icon-128.png are static files we write and place by hand. shadow compiles the logic; everything else is hand-assembled around it.

And there it is: a complete, unpacked extension folder. A Chromium browser would load public/ directly, as-is — point it at the folder and the extension runs. Safari will not. That refusal, and the native app it forces us to build, is Part 2.

Grounded in the organon Safari-capture extension, June 2026. Citations are file:line into the real source.

Citation colour marks the source — extension the extension’s own code, build / native the build-tool output and native side.

shadow-cljs.edn:1–18 manifest.json:1–14 popup.html:8–10 bb.edn:13–14 public/js/manifest.edn:1