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 toout/test.cjsand run outside any browser. shadow-cljs.edn:4–8- ◈
:dev :target :node-scripton Bun — the REPL runtime we drive development from (bun out/dev.jsstarts 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.
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
:backgroundbundle — the service-worker code, emitted by shadow-cljs fromorganon.ext.background. shadow-cljs.edn:13–15 - ▦
js/content.js - The compiled
:contentbundle — the content-script code, emitted fromorganon.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.jsis 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-containedbackground.jsof ~217 KB with no reference tocljs-runtime/at all — Closure has optimised the runtime into the one file. Thecljs-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.1access 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
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.