# Penpot exporter: the ClojureScript -> Node.js headless export service # (renders designs via Playwright/Chromium, talks to redis + the Penpot # internal API). # # Upstream builds this with a pnpm workspace + shadow-cljs and ships the # `target/` dir of exporter/scripts/build as the runtime bundle. This package # reproduces that bundle with everything materialized offline: node_modules # from a pre-fetched pnpm store and maven/git deps from the eval-time # clj-nix cache, and chromium comes # from nixpkgs instead of `playwright install`. (The render-wasm based # exporter rendering postdates the 2.17.x line this package tracks, so the # wasm artifacts are not consumed here.) { lib, stdenv, runCommand, callPackage, penpot, version, subSrc, nodejs_24, pnpm_11, fetchPnpmDeps, pnpmConfigHook, clojure, jdk25_headless, chromium, makeWrapper, imagemagick, poppler-utils, potrace, netpbm, makeFontsConf, noto-fonts-color-emoji, unifont, liberation_ttf, freefont_ttf, ipafont, wqy_zenhei, tlwg, }: let # The exporter workspace plus the `common` cljs sources it compiles in # (exporter/deps.edn and shadow-cljs.edn reference ../common). src = subSrc penpot [ "exporter" "common" ]; # exporter/deps.edn's :shadow-cljs alias passes # `--sun-misc-unsafe-memory-access=allow` to the JVM; that option needs # JDK >= 23 while nixpkgs' default clojure bundles JDK 21. clojureJdk25 = clojure.override { jdk = jdk25_headless; }; # npm dependencies of the exporter workspace, fetched once into a pnpm # store in a fixed-output derivation and materialized offline in the main # build via pnpmConfigHook. Lifecycle scripts are skipped: nothing in this # dependency set needs them for runtime (playwright's postinstall is # informational only), and the playwright browser download is replaced by # the browsers dir below. exporterSrc = subSrc penpot [ "exporter" ]; # Same storeDir workaround as frontend.nix: 2.18 adds a shared # `storeDir: ../.pnpm-store` to the workspace's pnpm-workspace.yaml, which # breaks the per-workspace fixed-output fetch and the offline install; # strip it in this pre-fetch source copy (and again in the consumer build's # postPatch below). Named "source" so the dir-source unpack lands where # sourceRoot expects it. exporterSrcP = runCommand "source" { } '' mkdir "$out" cp -r "${exporterSrc}"/. "$out"/ chmod -R u+w "$out" find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} + ''; pnpmDeps = fetchPnpmDeps { pname = "penpot-exporter"; inherit version; src = exporterSrcP; sourceRoot = "source/exporter"; pnpm = pnpm_11; # fetcherVersion 4 is required for pnpm 11 (older fetchers are rejected). fetcherVersion = 4; hash = "sha256-fGjKhjCEb4+2Nwd5xppow6YfjmgFQjXddHlsSSmr6Eo="; }; # Offline Clojure dependencies: eval-time cache from deps-lock.json # (no fixed-output derivation) + fake-git shim + short-SHA expansion. cljOffline = callPackage ./clj-offline.nix { }; magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; }; # Fonts matching the upstream exporter image (docker/Dockerfile.exporter): # emoji, bitmap fallback (covers the cyrillic/scalable X fonts), latin, # gothic, Thai (tlwg) and CJK coverage. fontsConf = makeFontsConf { fontDirectories = [ noto-fonts-color-emoji unifont liberation_ttf freefont_ttf ipafont wqy_zenhei tlwg ]; }; # Executable path layouts that playwright-core resolves below # $PLAYWRIGHT_BROWSERS_PATH (see its registry: EXECUTABLE_PATHS differ # between linux-x64 and linux-arm64). NOTE: these paths are # registry-version-dependent; re-verify against the vendored # playwright-core when the lockfile bumps playwright. # The headless-shell slot is served by the full chromium build (nixpkgs # does not package the separate headless shell). chromeDir = if stdenv.hostPlatform.isx86_64 then "chrome-linux64" else "chrome-linux"; headlessShellDir = if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell-linux64" else "chrome-linux"; headlessShellBin = if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell" else "headless_shell"; in stdenv.mkDerivation (finalAttrs: { pname = "penpot-exporter"; inherit version; inherit src; nativeBuildInputs = [ clojureJdk25 cljOffline.fake-git cljOffline.clj-builder makeWrapper nodejs_24 pnpm_11 pnpmConfigHook ]; env.NODE_ENV = "production"; inherit pnpmDeps; dontConfigure = true; # pnpmConfigHook is invoked manually inside installPhase (configure is # disabled), after cd'ing into the exporter workspace. dontPnpmConfigure = true; dontBuild = true; installPhase = '' runHook preInstall # Offline clojure dependency caches for the shadow-cljs release build. # Runs at the source root so patch-git-sha sees exporter/deps.edn and # common/deps.edn. The JVM resolves user.home from /etc/passwd rather # than $HOME; force it so tools.deps resolves from the eval-time cache # instead of attempting network downloads. ${cljOffline.setup} cd exporter # Strip 2.18's shared pnpm storeDir so the offline install uses the # fetched store (see the note on exporterSrcP). find .. -name pnpm-workspace.yaml -maxdepth 3 -exec sed -i '/^storeDir:/d' {} + # Materialize node_modules offline from the pre-fetched pnpm store. pnpmConfigHook clojure -M:dev:shadow-cljs release main # The compiled app.js carries a literal %version% placeholder # (upstream exporter/scripts/build substitutes it the same way). sed -i "s/%version%/${finalAttrs.version}/g" target/app.js # Assemble the runtime dir (the equivalent of upstream's target/). mkdir -p $out/lib/penpot-exporter cp target/app.js target/app.js.map $out/lib/penpot-exporter/ cp package.json pnpm-lock.yaml $out/lib/penpot-exporter/ # Upstream ships pnpm-workspace.yaml emptied out; keep the same. touch $out/lib/penpot-exporter/pnpm-workspace.yaml cp -a --preserve=links node_modules $out/lib/penpot-exporter/node_modules # Pre-materialized playwright browser dir: playwright-core resolves # $PLAYWRIGHT_BROWSERS_PATH/chromium-/... from its own # browsers.json, so derive the revision from the exporter's tree and # point both chromium flavors at the nixpkgs build. pwRevision=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).browsers.find(b => b.name === 'chromium').revision" \ node_modules/.pnpm/playwright-core@*/node_modules/playwright-core/browsers.json) browsers=$out/lib/penpot-exporter-browsers mkdir -p $browsers/chromium-$pwRevision/${chromeDir} ln -s ${chromium}/bin/chromium $browsers/chromium-$pwRevision/${chromeDir}/chrome mkdir -p $browsers/chromium_headless_shell-$pwRevision/${headlessShellDir} ln -s ${chromium}/bin/chromium \ $browsers/chromium_headless_shell-$pwRevision/${headlessShellDir}/${headlessShellBin} mkdir -p $out/bin makeWrapper ${nodejs_24}/bin/node $out/bin/penpot-exporter \ --add-flags "$out/lib/penpot-exporter/app.js" \ --chdir $out/lib/penpot-exporter \ --prefix PATH : ${ lib.makeBinPath [ imagemagick poppler-utils potrace netpbm nodejs_24 ] } \ --set NODE_ENV production \ --set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \ --set FONTCONFIG_FILE ${fontsConf} \ --set PLAYWRIGHT_BROWSERS_PATH $out/lib/penpot-exporter-browsers runHook postInstall ''; passthru.version = finalAttrs.version; meta = { description = "Penpot exporter service (headless design export via Chromium)"; license = lib.licenses.mpl20; platforms = lib.platforms.linux; mainProgram = "penpot-exporter"; }; })