Package Penpot 2.18.3 for Nix with NixOS module; afk devShell

Vendored from debling/nix-penpot (MIT). Fixes on top:
- pnpm fetchers: drop --config.fetch-* flags (UND_ERR_INVALID_ARG on pnpm 11.27)
  and pin real store hashes (frontend x3, exporter, mcp)
- deps-lock.json: add transitive funcool/beicon2 v2.0 so fake-git resolves
  potok2's pin during the shadow-cljs classpath build
- expose frontend pnpm stores via passthru for rehashing
- x86_64-linux only; devShell ships afk, jj, git
This commit is contained in:
kurogeek committed 2026-10-20 17:45:02 +00:00
commit 08b5496980
21 files changed
+7840

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.direnv/
/result
/result-*
/penpot
/penpot-vm.qcow2
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Denilson dos Santos Ebling
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+143
View File
@@ -0,0 +1,143 @@
# penpot-nix
Nix packages and a NixOS module for self-hosting [Penpot](https://penpot.app)
(pinned to release **2.18.3**). Every component is built from upstream
source: the Clojure API backend, the static frontend (served by nginx), the
headless-Chromium exporter, the MCP server and the Rust/WASM renderer.
One module wires the stack together with PostgreSQL and Valkey, either
provisioned locally or pointed at infrastructure you already run.
Derived from [debling/nix-penpot](https://github.com/debling/nix-penpot)
(MIT, see [LICENSE](LICENSE)). `x86_64-linux` only.
## Packages
| Attribute | Output |
|-----------------------------|------------------------------------------------------------------------|
| `penpot-backend` | `bin/penpot-backend` (JVM wrapper), `bin/penpot-manage` |
| `penpot-frontend` | `dist/` static bundle (+ `plugins/`, `plugins/mcp/`), `config.js` |
| `penpot-exporter` | `bin/penpot-exporter` (node + nixpkgs chromium) |
| `penpot-mcp` | `bin/penpot-mcp` |
| `penpot-render-wasm` | `render-wasm.{js,wasm}`, `shared.js`, `worker/render.js` (frontend) |
| `penpot-render-wasm-export` | export flavor of the renderer (unused on 2.18.x) |
The same packages are exposed as an overlay (`overlays.default`:
`pkgs.penpot-backend`, `pkgs.penpot-frontend`, `pkgs.penpot-exporter`,
`pkgs.penpot-mcp`). The module's package defaults resolve through the
overlay, so it is required.
## Deploying on NixOS
```nix
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
penpot-nix.url = "git+https://<this repo>";
};
outputs = { self, nixpkgs, penpot-nix }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
{ nixpkgs.overlays = [ penpot-nix.overlays.default ]; }
penpot-nix.nixosModules.default
];
};
};
}
```
```nix
# configuration.nix
{
services.penpot = {
enable = true;
publicUri = "https://penpot.example.com";
secretKeyFile = "/run/secrets/penpot-secret-key";
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
```
That is a complete deployment: PostgreSQL and Valkey are provisioned
locally, the nginx vhost is generated, exporter and MCP run behind it.
Secrets are files loaded through systemd credentials; generate the master
key once (`openssl rand -base64 64 | tr -d '\n'`) and manage it with
agenix/sops-nix.
Commonly tuned options (all documented inline in
[`modules/penpot.nix`](modules/penpot.nix)):
| Option | Default | What it does |
|----------------------------------|-----------------------------|-------------------------------------------------------------|
| `publicUri` | – (required) | Public URL of the instance |
| `secretKeyFile` | – (required) | Master secret key file |
| `flags` | `[ "enable-prepl-server" ]` | Upstream feature flags |
| `admins` | `[ ]` | Emails granted admin on login |
| `database.createLocally` | `true` | Local PostgreSQL, or set `database.uri` for an external one |
| `redis.createLocally` | `true` | Local Valkey, or set `redis.uri` |
| `storage.backend` | `"fs"` | `"fs"` or `"s3"` (+ endpoint/bucket/keys) |
| `smtp.*` / `mailpit.enable` | off | Outgoing email, or a local throwaway SMTP sink |
| `exporter.enable` / `mcp.enable` | `true` | Toggle the auxiliary services |
| `nginx.forceHttps` | `false` | Force HTTPS (configure TLS yourself) |
### Users and admins
No default credentials. Register through the UI, or on the backend host:
```bash
penpot-manage create-profile -n "Jane Doe" -e jane@example.org -p 'secret'
```
Admin rights come from `services.penpot.admins = [ "jane@example.org" ];`.
## Trying it in a VM
```bash
nix build .#nixosConfigurations.penpot-vm.config.system.build.vm
./result/bin/run-penpot-vm-vm
```
Host port 8080 → nginx, 8025 → mailpit UI. `curl http://127.0.0.1:8080/readyz`
once the backend is up (~1–2 min). Needs ~3 GB RAM.
## Development
```bash
direnv allow # or: nix develop — devShell ships the afk harness, jj, git
nix flake check --no-build
nix build .#checks.x86_64-linux.penpot-vm-test # end-to-end VM test
nix fmt
```
Development runs under [afk](https://github.com/DavHau/afk) (`afk` is on
PATH in the devShell).
## Updating Penpot
1. Bump `penpot.url` **and** `penpotVersion` in `flake.nix` (upstream's
`package.json` version is stale, so it is pinned by hand).
2. Regenerate `deps-lock.json` with clj-nix from a writable copy of the
penpot source with the `RELEASE` pins from `packages/frontend.nix`
(`pinDepsEdn`) applied:
```bash
nix run github:jlesquembre/clj-nix/<pinned-rev>#deps-lock -- \
--deps-include backend/deps.edn --deps-include common/deps.edn \
--deps-include frontend/deps.edn --deps-include exporter/deps.edn \
--alias-exclude outdated --alias-exclude jvm-repl
```
3. Rehash the fixed-output derivations (set to `lib.fakeHash`, build, copy
`got:`): `penpot-frontend.{frontendPnpmDeps,pluginsPnpmDeps,mcpPnpmDeps}`,
`penpot-exporter.pnpmDeps`, `penpot-mcp.pnpmDeps`,
`penpot-backend.builtinTemplates` (after `nix flake update penpot-files`),
and `cargoDeps` / Rust / Skia hashes in `packages/render-wasm.nix` if
`render-wasm/Cargo.lock`, the Rust version or the Skia URL changed.
4. Rebase `packages/patches/*` if `frontend/src/app/plugins/library.cljs`
moved.
## License
Nix code: MIT ([LICENSE](LICENSE)). Penpot: MPL-2.0. The remaining stack
(PostgreSQL, Valkey, Chromium, …) ships under its own licenses.
+3983
View File
File diff suppressed because it is too large. Load diff
Generated
+347
View File
@@ -0,0 +1,347 @@
{
"nodes": {
"afk": {
"inputs": {
"llm-agents": "llm-agents",
"nixpkgs": [
"nixpkgs"
],
"superpowers": "superpowers",
"wrappers": "wrappers"
},
"locked": {
"lastModified": 1792156344,
"narHash": "sha256-A9FdczKIes4uhqbpEh1yHY6RI6V2UqAou22SBgLDY/Y=",
"owner": "DavHau",
"repo": "afk",
"rev": "c1cc2da48640276ed39fd9035aef5005577cdde4",
"type": "github"
},
"original": {
"owner": "DavHau",
"repo": "afk",
"type": "github"
}
},
"bun2nix": {
"inputs": {
"flake-parts": [
"afk",
"llm-agents",
"flake-parts"
],
"nixpkgs": [
"afk",
"llm-agents",
"nixpkgs"
],
"systems": [
"afk",
"llm-agents",
"systems"
],
"treefmt-nix": [
"afk",
"llm-agents",
"treefmt-nix"
]
},
"locked": {
"lastModified": 1790001035,
"narHash": "sha256-1/SLBjSYBzDdDhvLQ83wrzBakyS/xNGgtxHGNuveOEQ=",
"owner": "Mic92",
"repo": "bun2nix",
"rev": "07a5bfc8ac36c5370199343fa620b69f63186e33",
"type": "github"
},
"original": {
"owner": "Mic92",
"ref": "llm-agents",
"repo": "bun2nix",
"type": "github"
}
},
"clj-nix": {
"inputs": {
"devshell": "devshell",
"nix-fetcher-data": "nix-fetcher-data",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1776760934,
"narHash": "sha256-WVzvTigFwWcJ0VkAFMzA3jE8kbHGahCJ0FJFqVYhPqM=",
"owner": "jlesquembre",
"repo": "clj-nix",
"rev": "2b1290ee56e9bbd50e9b5874c985d34ad2f1b458",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "clj-nix",
"rev": "2b1290ee56e9bbd50e9b5874c985d34ad2f1b458",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"afk",
"llm-agents",
"nixpkgs"
]
},
"locked": {
"lastModified": 1790898470,
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1719745305,
"narHash": "sha256-xwgjVUpqSviudEkpQnioeez1Uo2wzrsMaJKJClh+Bls=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c3c5ecc05edc7dafba779c6c1a61cd08ac6583e9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"llm-agents": {
"inputs": {
"bun2nix": "bun2nix",
"flake-parts": "flake-parts",
"nixpkgs": [
"afk",
"nixpkgs"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1792149628,
"narHash": "sha256-z6OJhOCXlLXdP1GdqS+6wLe/blU6KGZFrib+b/nxYE8=",
"ref": "refs/heads/main",
"rev": "e85b99217baf13a38fc887b31338b1833f1159c0",
"shallow": true,
"type": "git",
"url": "https://github.com/numtide/llm-agents.nix"
},
"original": {
"shallow": true,
"type": "git",
"url": "https://github.com/numtide/llm-agents.nix"
}
},
"nix-fetcher-data": {
"inputs": {
"flake-parts": "flake-parts_2",
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1755022803,
"narHash": "sha256-/QtBdVfZlrRJW5enUoWlBE2wrLXJBMJ45X0rZh0jiaU=",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"rev": "9da3926b1459d6ff15268072d1c51351b82811b9",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1792210252,
"narHash": "sha256-Mh0PUaIv4nFY+8wAGonaEh8iBzwsmdNlwjVHmFg1Biw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "09063bd1f2b36fd9fd793c36dfade7bd40a93dfc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1717284937,
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
}
},
"penpot": {
"flake": false,
"locked": {
"lastModified": 1791289550,
"narHash": "sha256-K5PkVl/3+CTtmFIrHRG3kMzPlkZZA5QYeyXHzGVb9wI=",
"owner": "penpot",
"repo": "penpot",
"rev": "ff7a66f51938b2293260ea27a64e2c4e0833b5e3",
"type": "github"
},
"original": {
"owner": "penpot",
"ref": "2.18.3",
"repo": "penpot",
"type": "github"
}
},
"penpot-files": {
"flake": false,
"locked": {
"lastModified": 1791873278,
"narHash": "sha256-KrfH2lXbbC50kw4T6cPZssk1PcUqKzZ5qHQo0JbxbbE=",
"owner": "penpot",
"repo": "penpot-files",
"rev": "be9b1c33ff7546f2a5246b4c52a96f1183c2fe35",
"type": "github"
},
"original": {
"owner": "penpot",
"ref": "main",
"repo": "penpot-files",
"type": "github"
}
},
"root": {
"inputs": {
"afk": "afk",
"clj-nix": "clj-nix",
"nixpkgs": "nixpkgs",
"penpot": "penpot",
"penpot-files": "penpot-files"
}
},
"superpowers": {
"flake": false,
"locked": {
"lastModified": 1790359587,
"narHash": "sha256-BWPiXoXV+jePP+wn/Z+Af4iehIL7oei00plaWaTzq8s=",
"ref": "main",
"rev": "8ca22dba9a94f28898bbce59f2537ff4d87c747d",
"shallow": true,
"type": "git",
"url": "https://github.com/obra/superpowers.git"
},
"original": {
"ref": "main",
"shallow": true,
"type": "git",
"url": "https://github.com/obra/superpowers.git"
}
},
"systems": {
"locked": {
"lastModified": 1791244623,
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
"owner": "nix-systems",
"repo": "default",
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"afk",
"llm-agents",
"nixpkgs"
]
},
"locked": {
"lastModified": 1791885023,
"narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"wrappers": {
"inputs": {
"nixpkgs": [
"afk",
"nixpkgs"
]
},
"locked": {
"lastModified": 1783783341,
"narHash": "sha256-zoxaqE02t2HdbDnDAAPbEJGFKDEaot52CepH5v39+f0=",
"ref": "refs/heads/main",
"rev": "b85926076618a9a57f450df8a660fba5880aeb21",
"shallow": true,
"type": "git",
"url": "https://github.com/lassulus/wrappers"
},
"original": {
"shallow": true,
"type": "git",
"url": "https://github.com/lassulus/wrappers"
}
}
},
"root": "root",
"version": 7
}
+197
View File
@@ -0,0 +1,197 @@
{
description = "penpot-nix — Nix packages and NixOS module for self-hosting Penpot";
nixConfig = {
extra-substituters = [ "https://nix-penpot.cachix.org" ];
extra-trusted-public-keys = [
"nix-penpot.cachix.org-1:cbXoCSnLa6QmBex5EYzUSsswqSZA6RvJWl0dR+WgOns="
];
};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
penpot.url = "github:penpot/penpot/2.18.3";
penpot.flake = false;
# Clojure dependency locking (deps-lock.json) + eval-time dep cache
# (mk-deps-cache) + offline git shim (fake-git). Pinned to a main
# commit: the only release (0.4.0, 2024) predates our toolchain.
# Only the overlay's clojure helpers are used (mk-deps-cache, fake-git,
# deps-lock, clj-builder); clj-nix's own apps/devshells stay unevaluated.
clj-nix.url = "github:jlesquembre/clj-nix/2b1290ee56e9bbd50e9b5874c985d34ad2f1b458";
clj-nix.inputs.nixpkgs.follows = "nixpkgs";
# Onboarding template files fetched by the backend build. No tags
# upstream, so track main; the locked rev makes it immutable. Bump with
# `nix flake update penpot-files` (see builtinTemplates in
# packages/backend.nix for the rehash procedure).
penpot-files.url = "github:penpot/penpot-files/main";
penpot-files.flake = false;
# Development harness: https://github.com/DavHau/afk
afk.url = "github:DavHau/afk";
afk.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{
self,
nixpkgs,
penpot,
penpot-files,
clj-nix,
afk,
}:
let
systems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor =
system:
import nixpkgs {
inherit system;
overlays = [
# Provides mk-deps-cache, fake-git, deps-lock, clj-builder.
clj-nix.overlays.default
];
};
penpotVersion =
# Keep in sync with the `penpot` input pin. Upstream leaves
# package.json's version stale ("2.17.0" even for 2.18.3), so
# mkPenpotVersion's package.json read lies; pin the real release
# instead. Changing this also changes asset version-tag URLs.
"2.18.3";
in
{
lib = {
mkPenpotVersion =
src:
let
pkg = builtins.readFile (src + "/package.json");
pkgVersion = builtins.match ".*\"version\": \"([^\"]+)\".*" pkg;
in
if pkgVersion != null then
builtins.head pkgVersion
else if src ? shortRev then
src.shortRev
else
"dev";
# Restrict a source tree to a set of top-level paths (directories or
# files). `paths` entries are matched against the path relative to
# `src`, so "backend" keeps "backend" itself and everything below it.
#
# Type: Path -> [String] -> Path
subSrc =
src: paths:
nixpkgs.lib.cleanSourceWith {
src = nixpkgs.lib.cleanSource src;
filter =
path: _type:
let
rel = nixpkgs.lib.removePrefix "${toString src}/" (toString path);
in
rel == "" || nixpkgs.lib.any (p: rel == p || nixpkgs.lib.hasPrefix "${p}/" rel) paths;
};
};
overlays.default = final: _prev: {
penpot-backend = self.packages.${final.stdenv.hostPlatform.system}.penpot-backend;
penpot-frontend = self.packages.${final.stdenv.hostPlatform.system}.penpot-frontend;
penpot-exporter = self.packages.${final.stdenv.hostPlatform.system}.penpot-exporter;
penpot-mcp = self.packages.${final.stdenv.hostPlatform.system}.penpot-mcp;
};
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
callPackage = pkgs.callPackage;
penpotRenderWasm = callPackage ./packages/render-wasm.nix {
inherit penpot;
version = penpotVersion;
};
in
{
penpot-backend = callPackage ./packages/backend.nix {
inherit penpot penpot-files;
version = penpotVersion;
inherit (self.lib) subSrc;
};
penpot-render-wasm = penpotRenderWasm;
# Exposed separately so CI/flake-check keeps building the export
# flavor (nothing consumes it on the 2.17.x line).
penpot-render-wasm-export = penpotRenderWasm.passthru.export;
penpot-frontend = callPackage ./packages/frontend.nix {
inherit penpot;
version = penpotVersion;
inherit (self.lib) subSrc;
};
penpot-exporter = callPackage ./packages/exporter.nix {
inherit penpot;
version = penpotVersion;
inherit (self.lib) subSrc;
};
penpot-mcp = callPackage ./packages/mcp.nix {
inherit penpot;
version = penpotVersion;
inherit (self.lib) subSrc;
};
default = self.packages.${system}.penpot-backend;
}
);
nixosModules = {
default = self.nixosModules.penpot;
penpot = import ./modules/penpot.nix;
};
nixosConfigurations.penpot-vm = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
{ nixpkgs.overlays = [ self.overlays.default ]; }
self.nixosModules.default
./tests/vm.nix
];
};
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
# Full end-to-end VM test of the module (boots PostgreSQL, Valkey,
# backend, nginx, exporter and MCP, then probes every endpoint).
penpot-vm-test = pkgs.testers.nixosTest (
import ./tests/vm-test.nix {
inherit (pkgs) lib;
inherit self;
}
);
}
);
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
afk.packages.${system}.afk
pkgs.jujutsu
pkgs.git
pkgs.nixfmt-tree
];
};
}
);
formatter = forAllSystems (system: (pkgsFor system).nixfmt-tree);
};
}
+1210
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
# sfnt2woff / woff2sfnt, Jonathan Kew's reference WOFF <-> sfnt converters.
# Required by the Penpot backend for font format conversion, but not packaged
# in nixpkgs. Upstream's page (people.mozilla.org/~jkew/woff) is offline, so
# this uses the source mirror several distributions rely on.
{
lib,
stdenv,
fetchFromGitHub,
zlib,
}:
stdenv.mkDerivation {
pname = "woff-tools";
version = "20091003";
src = fetchFromGitHub {
owner = "wget";
repo = "woff-tools";
rev = "20091003";
hash = "sha256-GDF07R4dNfVz9S1bWkDndq4Zy916kkuaWu6nXYlZRkU=";
};
buildInputs = [ zlib ];
installPhase = ''
runHook preInstall
install -Dm755 sfnt2woff $out/bin/sfnt2woff
install -Dm755 woff2sfnt $out/bin/woff2sfnt
install -Dm644 LICENSE $out/share/licenses/woff-tools/LICENSE
runHook postInstall
'';
meta = {
description = "Convert TrueType/OpenType (sfnt) fonts to WOFF format and back";
homepage = "https://github.com/wget/woff-tools";
# woff.c carries the classic Mozilla tri-license.
license = with lib.licenses; [
mpl11
gpl2Plus
lgpl21Plus
];
platforms = lib.platforms.linux;
mainProgram = "sfnt2woff";
};
}
+244
View File
@@ -0,0 +1,244 @@
# Penpot backend (Clojure API server), packaged as the upstream source
# uberjar. Reproduces `backend/scripts/build`: a non-AOT uberjar executed
# through `clojure.main`, shipped with the log4j2 config and the onboarding
# template files mapped from the pinned penpot-files flake input.
#
# The uberjar is repacked by packages/jar-strip-deterministic.py (in the penpot
# checkout), replacing stripJavaArchivesHook, whose reorder is O(n^2) and took
# ~32 minutes on this jar.
#
# Output contract:
# $out/share/penpot-backend/{penpot.jar,log4j2.xml,version.txt,manage.py}
# $out/share/penpot-backend/builtin-templates/
# $out/bin/penpot-backend (java wrapper, cwd = share dir)
# $out/bin/penpot-manage (manage.py CLI via python3 + tabulate)
{
lib,
stdenv,
makeWrapper,
callPackage,
clojure,
jdk25_headless,
babashka,
imagemagick,
fontforge,
woff2,
fontconfig,
util-linux,
python3,
penpot,
penpot-files,
version,
subSrc,
}:
let
src = subSrc penpot [
"backend"
"common"
"CHANGES.md"
];
# Replaces stripJavaArchivesHook: a stdlib-python deterministic repack of the
# uberjar, replicating strip-nondeterminism's jar/zip handlers (see the
# script's docstring for the full list). Its per-member reorder is O(n^2),
# which cost ~32 minutes on this jar's 54000 entries; this does it in ~20 s.
jarNormalizer = ./jar-strip-deterministic.py;
in
let
# sfnt2woff/woff2sfnt are not in nixpkgs; see backend-woff-tools.nix.
woff-tools = callPackage ./backend-woff-tools.nix { };
# The clojure CLI with a JDK matching the runtime (upstream run.sh flags
# need >= 24; the nixpkgs default CLI bundles JDK 21).
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; };
pythonEnv = python3.withPackages (ps: [ ps.tabulate ]);
# NOTE: upstream's docker image runs Zulu JDK 26 at 2.17.x; any JDK >= 24
# satisfies the run.sh flags, so the nixpkgs JDK 25 is used here. Revisit
# when bumping the penpot input.
# Binaries the backend shells out to at runtime (image/font processing),
# baked into the wrapper's PATH.
# util-linux: provides prlimit; upstream runs font-processing subprocesses
# under it (app.media.local/exec-font!), see also app.util.shell/prlimit-cmd.
runtimeBinPath = lib.makeBinPath [
imagemagick
fontforge
woff2
woff-tools
fontconfig
util-linux
];
# 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 { };
builtinTemplates = stdenv.mkDerivation {
name = "penpot-backend-builtin-templates";
src = "${src}/backend";
nativeBuildInputs = [
babashka
];
outputHashAlgo = "sha256";
outputHashMode = "recursive";
# The file contents come from the pinned penpot-files flake input, so
# this hash only changes when that input (or the template list in
# onboarding.edn) changes. On a penpot input bump: run
# `nix flake update penpot-files` first (a missing file fails the build
# loudly), then set to lib.fakeHash, build once, copy the `got:` hash
# from the failure message.
outputHash = "sha256-b03i34xCfKHie5AjT7is3o9kqIzcSU/nPl7mV1gu+u0=";
dontFixup = true;
dontBuild = true;
installPhase = ''
runHook preInstall
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Map onboarding.edn (id, file-uri) pairs to files from the pinned
# penpot-files input. EDN-native parsing via babashka; basenames are
# URL-decoded (upstream file-uris contain %20 etc.).
cat > "$TMPDIR/map-templates.clj" <<'EOF'
(require '[babashka.fs :as fs]
'[clojure.edn :as edn]
'[clojure.string :as str])
(import '[java.net URLDecoder])
(let [[defs-path files-dir dest] *command-line-args*
data (edn/read-string (slurp defs-path))]
(fs/create-dirs dest)
(doseq [{:keys [id file-uri]} data]
;; Basename of file-uri, minus any query/fragment; literal `+`
;; is protected before URL-decoding (`URLDecoder` maps `+` to
;; space, but upstream names use `%20` for spaces).
(let [fname (-> file-uri
(str/split #"/") last
(str/split #"[?#]") first
(str/replace "+" "%2B")
(URLDecoder/decode "UTF-8"))
src-file (fs/file files-dir fname)]
(when-not (fs/exists? src-file)
(println (format "template file %s (id: %s) not found" fname id))
(System/exit 1))
(println (format "=> installing %s" id))
(fs/copy src-file (fs/file dest id)))))
EOF
bb "$TMPDIR/map-templates.clj" \
resources/app/onboarding.edn \
"${penpot-files}" \
"$out/templates"
runHook postInstall
'';
};
in
stdenv.mkDerivation {
pname = "penpot-backend";
inherit version src;
nativeBuildInputs = [
clojureJdk25
cljOffline.fake-git
cljOffline.clj-builder
makeWrapper
# Repacks the jar deterministically during fixup. Replaces
# stripJavaArchivesHook, whose O(n^2) member reorder needs ~32 minutes on
# this jar (54000 entries); see jarNormalizer above.
python3
];
configurePhase = ''
runHook preConfigure
# Offline dependency resolution from the eval-time clj-nix cache.
${cljOffline.setup}
runHook postConfigure
'';
buildPhase = ''
runHook preBuild
(
cd backend
mkdir -p target/classes
echo "$version" > target/classes/version.txt
cp ../CHANGES.md target/classes/changelog.md
clojure -T:build jar
)
runHook postBuild
'';
installPhase = /* sh */ ''
runHook preInstall
share="$out/share/penpot-backend"
mkdir -p "$share" "$out/bin"
install -Dm644 backend/target/penpot.jar "$share/penpot.jar"
install -Dm644 backend/resources/log4j2.xml "$share/log4j2.xml"
cp -r "${builtinTemplates}/templates" "$share/builtin-templates"
echo "$version" > "$share/version.txt"
# Deterministic repack (see jarNormalizer). The uber task stamps
# entries with wall-clock time, so this can't be skipped.
python3 ${jarNormalizer} "$share/penpot.jar"
# manage.py hardcodes the prepl endpoint as the argparse default; patch
# it to honor the PREPL_URI environment variable instead.
install -Dm755 backend/scripts/manage.py "$share/manage.py"
substituteInPlace "$share/manage.py" \
--replace-fail 'import argparse' 'import argparse
import os' \
--replace-fail 'default="tcp://localhost:6063"' 'default=os.environ.get("PREPL_URI", "tcp://localhost:6063")'
# Flags mirror backend/scripts/run.template.sh, except
# --add-opens=java.base/java.nio (not upstream; needed by
# yetti/lettuce for direct NIO buffer access on modern JDKs).
# $JAVA_OPTS and $PENPOT_ENTRYPOINT are embedded verbatim by
# makeWrapper and expand at runtime, like in the upstream run.sh.
makeWrapper "${jdk25_headless}/bin/java" "$out/bin/penpot-backend" \
--chdir "$share" \
--prefix PATH : "${runtimeBinPath}" \
--set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \
--set-default PENPOT_ENTRYPOINT app.main \
--add-flags '-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager' \
--add-flags '-Dlog4j2.configurationFile=log4j2.xml' \
--add-flags '-XX:-OmitStackTraceInFastThrow' \
--add-flags '--sun-misc-unsafe-memory-access=allow' \
--add-flags '--enable-native-access=ALL-UNNAMED' \
--add-flags '--add-opens=java.base/java.nio=ALL-UNNAMED' \
--add-flags '--enable-preview' \
--add-flags '$JAVA_OPTS' \
--add-flags '-jar penpot.jar -m $PENPOT_ENTRYPOINT'
makeWrapper "${pythonEnv}/bin/python" "$out/bin/penpot-manage" \
--add-flags "$share/manage.py"
runHook postInstall
'';
passthru = {
inherit builtinTemplates;
version = version;
};
meta = {
description = "Penpot backend API server (Clojure uberjar)";
homepage = "https://penpot.app";
license = lib.licenses.mpl20;
platforms = lib.platforms.linux;
mainProgram = "penpot-backend";
};
}
+46
View File
@@ -0,0 +1,46 @@
# Shared offline Clojure dependency wiring, backed by clj-nix.
#
# `mk-deps-cache` materializes `deps-lock.json` (committed at the repo root)
# purely at eval time — `fetchurl` per Maven artifact, `fetchgit` per git
# dep — so consumer builds resolve fully offline and deterministically, with
# no fixed-output derivation involved.
#
# Consumers: point tools.deps at the cache (same recipe as clj-nix's own
# builder), expand short `:git/sha` pins to full SHAs from the lock, and
# keep `fake-git` (a `git` shim answering rev-parse/tag/merge-base from lock
# metadata) on PATH for any stray git invocation.
{
mk-deps-cache,
fake-git,
clj-builder,
}:
let
cache = mk-deps-cache {
lockfile = ../deps-lock.json;
};
in
{
inherit cache fake-git clj-builder;
# Shell fragment for consumer preBuild/configure phases. Must run in the
# project root (the directory `deps-lock.json` is copied into), before any
# `clojure` invocation. Uses a writable HOME with symlinks into the
# read-only cache (pnpm and friends need a writable HOME; clojure
# resolution follows the symlinks).
setup = ''
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
ln -sfn "${cache}/.m2" "$HOME/.m2"
ln -sfn "${cache}/.gitlibs" "$HOME/.gitlibs"
ln -sfn "${cache}/.clojure" "$HOME/.clojure"
# The JVM resolves user.home from /etc/passwd rather than $HOME; force
# it so tools.deps resolves from the cache instead of attempting
# network downloads.
export JAVA_TOOL_OPTIONS="-Duser.home=$HOME"
export CLJ_CONFIG="$HOME/.clojure"
export CLJ_CACHE="$TMPDIR/cp_cache"
export GITLIBS="$HOME/.gitlibs"
cp "${../deps-lock.json}" deps-lock.json
clj-builder patch-git-sha "$(pwd)"
'';
}
+223
View File
@@ -0,0 +1,223 @@
# 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-<rev>/... 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";
};
})
+247
View File
@@ -0,0 +1,247 @@
# Penpot frontend: compiles the upstream ClojureScript/JS monorepo into the
# static site served by nginx ($out/dist, mirroring upstream `bundles/frontend`).
#
# Mirrors upstream `frontend/scripts/build`:
# 1. render-wasm artifacts are placed where the wasm cljs glue expects them
# (instead of building the wasm in-tree).
# 2. MCP plugin is built (its own pnpm workspace).
# 3. Official plugins are built (their own pnpm workspace); the plugins
# runtime library is built first since the cljs build links it.
# 4. shadow-cljs release build of the `main` and `worker` builds.
# 5. esbuild bundling of the JS libs.
# 6. Asset compilation (styles, sprites, translations, templates, ...).
# 7. Finalization: worker render.js cache-busting, rsync into dist,
# plugins, version.txt.
{
lib,
callPackage,
penpot,
version,
subSrc,
stdenv,
stdenvNoCC,
runCommand,
nodejs_24,
pnpm_11,
clojure,
jdk25_headless,
rsync,
patchelf,
fetchPnpmDeps,
pnpmConfigHook,
# Named renderWasm (not `wasm`) so pkgs.callPackage cannot auto-fill it
# with nixpkgs' unrelated `pkgs.wasm` (the OCaml interpreter).
renderWasm ? callPackage ./render-wasm.nix { inherit penpot version; },
}:
let
src = subSrc penpot [
"frontend"
"common"
"mcp"
"plugins"
];
# The frontend workspace links `../plugins/libs/plugins-runtime` during
# install, so plugins/ must be present in its fetch source too.
frontendSrc = subSrc penpot [
"frontend"
"plugins"
];
pluginsSrc = subSrc penpot [ "plugins" ];
mcpSrc = subSrc penpot [ "mcp" ];
# One pnpm store per workspace (fetched once, reused for the offline
# installs in the main build via pnpmConfigHook).
#
# 2.18 adds a shared `storeDir: ../.pnpm-store` to every workspace's
# pnpm-workspace.yaml; our fetches and consumer installs are per-workspace,
# so strip that line before the fixed-output fetch (the main build removes
# it again in postPatch).
patchedPnpmSrc =
name: wsrc:
# Named "source" (see the note on `mcpSrcP` in mcp.nix): the deps FOD
# relies on the dir-source unpack landing at /build/source so that
# sourceRoot = "source/<workspace>" resolves.
runCommand "source" { } ''
mkdir "$out"
cp -r "${wsrc}"/. "$out"/
chmod -R u+w "$out"
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
'';
fetchWorkspaceDeps =
pname: sourceRoot: wsrc: hash:
fetchPnpmDeps {
inherit pname sourceRoot hash;
pnpm = pnpm_11;
fetcherVersion = 4;
src = patchedPnpmSrc pname wsrc;
};
frontendPnpmDeps =
fetchWorkspaceDeps "penpot-frontend-frontend" "source/frontend" frontendSrc
"sha256-zZxUFuyWLMxIse5AjPwEAmq/fJ9tbGYkcG2kG68HV4U=";
pluginsPnpmDeps =
fetchWorkspaceDeps "penpot-frontend-plugins" "source/plugins" pluginsSrc
"sha256-Tq6aSL0lHFCfQo5H94d+XNGeY2oMNYG2J8YOQhYl3T8=";
mcpPnpmDeps =
fetchWorkspaceDeps "penpot-frontend-mcp" "source/mcp" mcpSrc
"sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
# Upstream's deps.edn passes --sun-misc-unsafe-memory-access=allow, which
# needs a newer JDK than the one the clojure CLI wrapper ships with.
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
# Upstream leaves a few dev deps on "RELEASE"; pin them so the lockfile
# generation and the (offline) shadow-cljs build resolve the same
# deterministic, purely local maven versions.
pinDepsEdn = ''
sed -i frontend/deps.edn \
-e 's|binaryage/devtools {:mvn/version "RELEASE"}|binaryage/devtools {:mvn/version "1.0.7"}|' \
-e 's|org.clojure/tools.namespace {:mvn/version "RELEASE"}|org.clojure/tools.namespace {:mvn/version "1.5.1"}|' \
-e 's|com.bhauman/rebel-readline {:mvn/version "RELEASE"}|com.bhauman/rebel-readline {:mvn/version "0.1.11"}|'
'';
# Offline Clojure dependencies: eval-time cache from deps-lock.json
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
# Resolution runs offline from the cache on every build; no cpcache needed.
cljOffline = callPackage ./clj-offline.nix { };
in
stdenvNoCC.mkDerivation {
pname = "penpot-frontend";
inherit version;
inherit src;
# Local carries of upstream plugin API fixes (see the patches for the
# upstream issue references). Remove once the `penpot` input contains them.
patches = [
./patches/plugin-library-name-path.patch
./patches/plugin-typography-lineheight.patch
];
nativeBuildInputs = [
nodejs_24
pnpm_11
pnpmConfigHook
clojureJdk25
cljOffline.fake-git
cljOffline.clj-builder
rsync
patchelf
];
# pnpmConfigHook is invoked once per workspace in preBuild instead
# (dontPnpmConfigure disables the implicit single-workspace run).
dontPnpmConfigure = true;
postPatch = ''
${pinDepsEdn}
# 2.18's shared pnpm store config would redirect the offline installs
# away from the fetched per-workspace stores.
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
'';
env = {
NODE_ENV = "production";
# Baked into the cljs output (app.config/compiled-version-tag).
VERSION = version;
# Upstream appends the wall-clock build time (VERSION_TAG =
# "$VERSION-$BUILD_TS"); a plain version keeps the build deterministic
# while still busting caches. TEMPORARY: the -fix suffix forces clients
# to fetch the patched bundle while the local patches are carried.
VERSION_TAG = "${version}-fix2";
# _helpers.js embeds these in the bundle when set; pin them for
# reproducibility (upstream exports the wall clock).
BUILD_DATE = "Thu Jan 01 00:00:00 UTC 1970";
BUILD_TS = "0";
};
preBuild = ''
# pnpmConfigHook honors two variables: $pnpmRoot (where the workspace
# lives; the hook pushd's into it) and $pnpmDeps (the fetched store,
# visible to the hook via bash dynamic scoping from the local).
installWorkspace() {
local pnpmRoot="$1" pnpmDeps="$2"
pnpmConfigHook
}
installWorkspace frontend ${frontendPnpmDeps}
installWorkspace plugins ${pluginsPnpmDeps}
installWorkspace mcp ${mcpPnpmDeps}
# sass-embedded ships a prebuilt dart VM expecting the host loader.
sassPkg=${
if stdenvNoCC.hostPlatform.isx86_64 then "sass-embedded-linux-x64" else "sass-embedded-linux-arm64"
}
patchelf --set-interpreter "$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)" \
frontend/node_modules/.pnpm/$sassPkg@*/node_modules/$sassPkg/dart-sass/src/dart
'';
buildPhase = ''
runHook preBuild
# 1. render-wasm artifacts.
mkdir -p frontend/src/app/render_wasm/api frontend/resources/public/js/worker
install -m644 ${renderWasm}/render-wasm.js ${renderWasm}/render-wasm.wasm frontend/resources/public/js/
install -m644 ${renderWasm}/worker/render.js frontend/resources/public/js/worker/render.js
install -m644 ${renderWasm}/shared.js frontend/src/app/render_wasm/api/shared.js
# 2. MCP plugin.
(cd mcp && WS_URI=/mcp/ws pnpm run --filter mcp-plugin build)
# 3. Official plugins.
(cd plugins/libs/plugins-runtime && pnpm run build)
(cd plugins && pnpm run build:plugins)
# 4. Main cljs build, resolving offline from the eval-time clj-nix cache.
${cljOffline.setup}
(cd frontend && pnpm run build:app:main)
(cd frontend && pnpm run build:app:libs)
(cd frontend && pnpm run build:app:assets)
# 5. Finalize the compiled assets (the dist assembly itself happens in
# installPhase).
sed -i "s|\./render.js|./render.js?version=$version|g" frontend/resources/public/js/worker/main*.js
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/dist
rsync -a frontend/resources/public/ $out/dist/
rsync -a plugins/dist/apps/ $out/dist/plugins/
mkdir -p $out/dist/plugins/mcp
rsync -a mcp/packages/plugin/dist/ $out/dist/plugins/mcp/
echo "$version" > $out/dist/version.txt
install -m644 ${penpot}/LICENSE $out/dist/LICENSE
# The NixOS module re-renders flags into the config template at system
# build time; upstream also serves it from dist/js/config.js.
install -m644 ${penpot}/docker/images/files/config.js $out/config.js
install -m644 ${penpot}/docker/images/files/config.js $out/dist/js/config.js
runHook postInstall
'';
# The pnpm stores are exposed so a version bump can rehash them directly
# (`nix build .#penpot-frontend.<name>` with hash = lib.fakeHash).
passthru = {
inherit
version
frontendPnpmDeps
pluginsPnpmDeps
mcpPnpmDeps
;
};
meta = {
description = "Penpot frontend static bundle";
homepage = "https://penpot.app";
license = lib.licenses.mpl20;
platforms = lib.platforms.linux;
};
}
+11
View File
@@ -0,0 +1,11 @@
# Hardened ImageMagick policy shipped by the upstream images (resource caps
# and PS/EPS/PDF/XPS coders disabled); nixpkgs' default policy is
# unrestricted and the backend/exporter process user-controlled content.
{
runCommand,
penpot,
}:
runCommand "penpot-imagemagick-policy" { } ''
install -Dm644 "${penpot}/docker/images/files/imagemagick-policy.xml" \
"$out/etc/ImageMagick-7/policy.xml"
''
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Deterministic repack of a jar, replicating stripJavaArchivesHook for the
penpot backend uberjar.
Nix's stripJavaArchivesHook runs strip-nondeterminism, which drives the
perl Archive::Zip module. Two problems made it unusable here:
* its per-member removeMember/addMember reorder loop is O(n^2) in the entry
count (linear member search + full array rebuild per entry), so the
54000-entry, 109 MB penpot jar spent ~32 minutes there;
* Archive::Zip derives the DOS timestamps with localtime, so the result
depended on the build machine's timezone.
This script reproduces the same semantics (jar.pm + zip.pm handlers) with the
stdlib zipfile module: read each entry once, sort the entry list, write it in a
single pass. The result is byte-identical across runs and timezones, and costs
seconds instead of minutes.
Semantics replicated from strip-nondeterminism:
* entry order: META-INF/ first, then META-INF/MANIFEST.MF, then lexicographic
* canonical time: Archive::Zip SAFE_EPOCH (1980-01-01 12:01:00 local), used
here as a fixed UTC tuple so the output never depends on $TZ
* .clj entries are stamped 2 DOS seconds (1 unix second) earlier, so that
Clojure never mistakes a .clj for a stale .class
* META-INF/MANIFEST.MF: drop Bnd-LastModified:/Built-By: lines
* .html with a `<!-- Generated by javadoc` marker in the first 1 KB: bump
the generation date to the canonical date
* nested .jar entries: recurse with the same semantics
* extra fields 0x5455 (extended timestamps), 0x000a (NTFS), 0x7875 (ux
uid/gid) are normalized; unknown fields are kept verbatim
* archives with a META-INF/*.SF signature are left untouched
* javac.sh and plexus compiler-arguments files are dropped
"""
import io
import os
import re
import struct
import sys
import tempfile
import zipfile
# Archive::Zip SAFE_EPOCH: 1980-01-01 12:01:00. The DOS timestamp below is the
# same instant, but written as a fixed tuple: no localtime call on the way out.
DOS_DATE = (1980, 1, 1)
DOS_TIME = (12, 1, 0)
DOS_EPOCH_SECONDS = 315576060
MANIFEST = "META-INF/MANIFEST.MF"
MANIFEST_DIR = "META-INF/"
JAVADOC_WINDOW = 1024
JAVADOC_MARKER = b"<!-- Generated by javadoc"
# From jar.pm's extract helper: a `YYYY-MM-DD` following this class is the
# javadoc generation timestamp.
JAVADOC_DATE_RE = re.compile(rb"(<!-- Generated by javadoc[^>]*?\son\s)(\d{4}-\d{2}-\d{2})")
def order_key(name: str):
if name == MANIFEST_DIR:
return (0, name)
if name == MANIFEST:
return (1, name)
return (2, name)
def dropped(name: str) -> bool:
return name == "javac.sh" or re.fullmatch(
r"org\.codehaus\.plexus\.compiler\.javac\.JavacCompiler\d+arguments", name
) is not None
def canonical_tuple(entry_name: str):
if entry_name.endswith(".clj"):
# 2 DOS seconds earlier, i.e. one unix second (DOS has 2 s granularity).
seconds = DOS_TIME[0] * 3600 + DOS_TIME[1] * 60 + DOS_TIME[2] - 2
return DOS_DATE + (seconds // 3600, (seconds % 3600) // 60, seconds % 60)
return DOS_DATE + DOS_TIME
def normalize_manifest(data: bytes) -> bytes:
out = []
for line in data.splitlines(keepends=True):
if line.startswith(b"Bnd-LastModified:") or line.startswith(b"Built-By:"):
continue
out.append(line)
return b"".join(out)
def normalize_javadoc(data: bytes) -> bytes:
head = data[:JAVADOC_WINDOW]
if JAVADOC_MARKER not in head:
return data
def repl(match):
return match.group(1) + b"1980-01-01"
return JAVADOC_DATE_RE.sub(repl, data, count=1)
def normalize_extra(extra: bytes, filename: str) -> bytes:
"""Normalize known timestamp/identity extra fields, keep the rest as-is."""
want = canonical_tuple(filename)
out = bytearray()
pos = 0
while pos + 4 <= len(extra):
field_id, field_len = struct.unpack_from("<HH", extra, pos)
data = extra[pos + 4 : pos + 4 + field_len]
if field_id == 0x5455: # extended timestamp
if len(data) >= 5:
data = data[:1] + b"\x00\x00\x00\x00" + data[5:]
elif field_id == 0x000a: # NTFS timestamp
if len(data) >= 4:
reserved = data[:4]
tags = bytearray()
tag_pos = 4
while tag_pos + 4 <= len(data):
tag, tag_size = struct.unpack_from("<HH", data, tag_pos)
tags += data[tag_pos : tag_pos + 4]
if tag == 0x0001 and tag_size == 24:
# 32 bytes of 100ns ticks since 1601, i.e. 12 bytes of
# zeros followed by the unix seconds in the low word.
ticks = b"\x00" * 12 + struct.pack("<I", int(DOS_EPOCH_SECONDS))
tags += ticks
else:
tags += data[tag_pos + 4 : tag_pos + 4 + tag_size]
tag_pos += 4 + tag_size
data = reserved + bytes(tags)
elif field_id == 0x7875: # Info-ZIP new unix extra field
if len(data) >= 3 and data[0] == 1:
uid_len, gid_len = data[1], data[2 + data[1]]
data = (
b"\x01"
+ bytes([uid_len])
+ b"\x00" * uid_len
+ bytes([gid_len])
+ b"\x00" * gid_len
)
out += struct.pack("<HH", field_id, len(data)) + data
pos += 4 + field_len
return bytes(out)
def normalize_member(name: str, data: bytes) -> bytes:
if name == MANIFEST:
return normalize_manifest(data)
if name.endswith(".html"):
return normalize_javadoc(data)
if name.endswith(".jar"):
return normalize_jar_bytes(data)
return data
def _rewrite(source, target):
zin = zipfile.ZipFile(source)
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
# Signed jar: normalization would break the signature.
return False, 0
with zipfile.ZipFile(target, "w") as zout:
written = 0
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
if dropped(item.filename):
continue
data = normalize_member(item.filename, zin.read(item.filename))
date_time = canonical_tuple(item.filename)
info = zipfile.ZipInfo(item.filename, date_time=date_time)
info.compress_type = item.compress_type
info.external_attr = item.external_attr
info.internal_attr = item.internal_attr
info.create_system = 3
info.extra = normalize_extra(item.extra, item.filename)
zout.writestr(info, data)
written += 1
return True, written
def normalize_jar_bytes(data: bytes) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(data)) as zin:
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
return data
with zipfile.ZipFile(buffer, "w") as zout:
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
if dropped(item.filename):
continue
info = zipfile.ZipInfo(item.filename, date_time=canonical_tuple(item.filename))
info.compress_type = item.compress_type
info.external_attr = item.external_attr
info.internal_attr = item.internal_attr
info.create_system = 3
info.extra = normalize_extra(item.extra, item.filename)
zout.writestr(info, normalize_member(item.filename, zin.read(item.filename)))
return buffer.getvalue()
def main() -> int:
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <jar>", file=sys.stderr)
return 2
path = sys.argv[1]
tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".jar")
os.close(tmp_fd)
try:
handled, entries = _rewrite(path, tmp_path)
if not handled:
print(f"jar-strip: {path}: signed jar, leaving untouched", file=sys.stderr)
return 0
if entries == 0:
print(f"jar-strip: {path}: no entries written", file=sys.stderr)
return 1
# Self-validation: reopen and verify every CRC before trusting the output.
with zipfile.ZipFile(tmp_path) as check:
bad = check.testzip()
if bad is not None:
print(f"jar-strip: {path}: CRC mismatch at {bad}", file=sys.stderr)
return 1
os.replace(tmp_path, path)
print(f"jar-strip: {path}: normalized {entries} entries")
return 0
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
if __name__ == "__main__":
sys.exit(main())
+117
View File
@@ -0,0 +1,117 @@
# Penpot MCP server (upstream `mcp/` pnpm workspace).
#
# The esbuild bundle (packages/server/dist/index.js) keeps the node
# dependencies external, so the runtime needs a real node_modules tree.
# We materialize it offline with pnpm's `deploy` from the fetched store.
{
lib,
penpot,
version,
subSrc,
stdenv,
stdenvNoCC,
runCommand,
makeWrapper,
nodejs_24,
pnpm_11,
fetchPnpmDeps,
pnpmConfigHook,
}:
let
# Only the mcp/ workspace is needed from the Penpot source tree.
mcpSrc = subSrc penpot [ "mcp" ];
# 2.18 adds a shared `storeDir: ../.pnpm-store` to the workspace's
# pnpm-workspace.yaml; our pnpm fetches are per-workspace fixed-output
# derivations, so strip the line before the fetch (postPatch strips the
# consumer copy).
# Named "source": stdenv's unpack of a directory source copies it to a
# dir named after `stripHash(src)`, and the fetch below relies on
# sourceRoot = "source/mcp" (matching the raw subSrc trees, which are
# also named "source" in the store).
mcpSrcP = runCommand "source" { } ''
mkdir "$out"
cp -r "${mcpSrc}"/. "$out"/
chmod -R u+w "$out"
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
'';
in
stdenv.mkDerivation (finalAttrs: {
pname = "penpot-mcp";
inherit version;
src = mcpSrcP;
# The filtered source tree keeps the upstream `mcp/` directory;
# the workspace (pnpm-lock.yaml, pnpm-workspace.yaml) lives there.
sourceRoot = "source/mcp";
# Fixed-output fetch of the whole pnpm store (fetcherVersion 3 is
# unsupported for pnpm 11).
pnpmDeps = fetchPnpmDeps {
pname = "penpot-mcp";
inherit (finalAttrs) src sourceRoot;
pnpm = pnpm_11;
fetcherVersion = 4;
hash = "sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
};
# 2.18's shared pnpm store config would redirect the offline install away
# from the fetched store.
postPatch = ''
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
'';
nativeBuildInputs = [
makeWrapper
nodejs_24
pnpm_11
pnpmConfigHook
];
# Same build as upstream `mcp/scripts/build` (build-types is not part of
# it; ApiDocs loads the committed data/api_types.yml at runtime).
buildPhase = ''
runHook preBuild
pnpm -r --filter "mcp-server" run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
# Produce a standalone production runtime for the server. Unlike the
# upstream dist/ bundle we materialize node_modules now (from the
# offline store) instead of shipping a `setup` script that would run
# `pnpm install` on the user's machine. The esbuild bundle keeps the
# node dependencies external, so they must ship alongside it.
pnpm --filter mcp-server \
--config.inject-workspace-packages=true \
deploy --prod --offline deploy
mkdir -p $out/lib/penpot-mcp $out/bin
# dist contains index.js, static/ and data/ (instructions + api types).
cp -a deploy/dist/. $out/lib/penpot-mcp/
cp deploy/package.json $out/lib/penpot-mcp/package.json
cp -a deploy/node_modules $out/lib/penpot-mcp/node_modules
# The server resolves data/ relative to the working directory, so the
# wrapper chdirs into the runtime before exec'ing. Defaults to the
# multi-user docker runtime; override arguments with PENPOT_MCP_ARGS.
makeWrapper ${lib.getExe nodejs_24} $out/bin/penpot-mcp \
--chdir $out/lib/penpot-mcp \
--set-default PENPOT_MCP_SERVER_HOST 127.0.0.1 \
--add-flags 'index.js' \
--add-flags ''${PENPOT_MCP_ARGS:---multi-user}
runHook postInstall
'';
passthru = { inherit (finalAttrs) version pnpmDeps; };
meta = {
description = "Penpot MCP server (Model Context Protocol integration for Penpot)";
license = lib.licenses.mpl20;
platforms = lib.platforms.linux;
mainProgram = "penpot-mcp";
};
})
@@ -0,0 +1,161 @@
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
index c7895ef7..fda13133 100644
--- a/frontend/src/app/plugins/library.cljs
+++ b/frontend/src/app/plugins/library.cljs
@@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.files.variant :as cfv]
[app.common.geom.point :as gpt]
+ [app.common.path-names :as cpn]
[app.common.schema :as sm]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
@@ -32,11 +33,25 @@
[app.plugins.utils :as u]
[app.util.object :as obj]
[beicon.v2.core :as rx]
+ [cuerdas.core :as str]
[potok.v2.core :as ptk]))
(declare lib-color-proxy)
(declare lib-typography-proxy)
+(defn- rename-path-name
+ "Compose the full path string used by the library rename events from an
+ item and a new name. A `value` that contains a path separator is
+ interpreted as an absolute path (matching the UI rename field); any
+ other value keeps the item's current path. This makes the setters
+ idempotent: assigning back a value read from `name`/`path` (e.g.
+ \"Group / Name\") no longer appends the current path again and moves
+ the element to a nested group on every set."
+ [item value]
+ (if (str/includes? value "/")
+ value
+ (cpn/merge-path-item (d/nilv (:path item) "") value)))
+
(defn lib-color-proxy? [p]
(obj/type-of? p "LibraryColorProxy"))
@@ -60,7 +75,7 @@
:set
(fn [self value]
(cond
- (not (string? value))
+ (or (not (string? value)) (str/empty? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
@@ -68,7 +83,7 @@
:else
(let [color (u/proxy->library-color self)
- value (dm/str (d/nilv (:path color) "") " / " value)]
+ value (rename-path-name color value)]
(st/emit! (dwl/rename-color file-id id value)))))}
:path
@@ -307,7 +322,7 @@
:set
(fn [self value]
(cond
- (not (string? value))
+ (or (not (string? value)) (str/empty? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
@@ -315,7 +330,7 @@
:else
(let [typo (u/proxy->library-typography self)
- value (dm/str (d/nilv (:path typo) "") " / " value)]
+ value (rename-path-name typo value)]
(st/emit! (-> (dwl/rename-typography file-id id value)
(se/add-event plugin-id))))))}
@@ -754,7 +769,7 @@
:set
(fn [self value]
(cond
- (not (string? value))
+ (or (not (string? value)) (str/empty? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
@@ -762,7 +777,7 @@
:else
(let [component (u/proxy->library-component self)
- value (dm/str (d/nilv (:path component) "") " / " value)]
+ value (rename-path-name component value)]
(st/emit! (dwv/rename-comp-or-variant-and-main id value)))))}
:path
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
index 32da4f5a..4bb5d4d4 100644
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
@@ -98,6 +98,22 @@ describe('Library', () => {
expect(color.opacity).toBeCloseTo(0.8, 2);
});
+ // The `name` setter must be idempotent. Assigning a value that already
+ // contains the path (the composite shown in the assets panel) must not
+ // append the current path again and nest the element deeper on each set.
+ test('color name with a path is idempotent', (ctx) => {
+ const color = ctx.penpot.library.local.createColor();
+ color.name = 'plugingroup/Name';
+ expect(color.path).toBe('plugingroup');
+ expect(color.name).toBe('Name');
+ color.name = 'plugingroup/Name';
+ expect(color.path).toBe('plugingroup');
+ expect(color.name).toBe('Name');
+ color.name = 'Simple';
+ expect(color.path).toBe('plugingroup');
+ expect(color.name).toBe('Simple');
+ });
+
test('library color converts to fill and stroke', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.color = '#123456';
@@ -188,6 +204,19 @@ describe('Library', () => {
expect(typo.path).toBe('Text');
});
+ test('typography name with a path is idempotent', (ctx) => {
+ const typo = ctx.penpot.library.local.createTypography();
+ typo.name = 'Text/Heading';
+ expect(typo.path).toBe('Text');
+ expect(typo.name).toBe('Heading');
+ typo.name = 'Text/Heading';
+ expect(typo.path).toBe('Text');
+ expect(typo.name).toBe('Heading');
+ typo.name = 'Body';
+ expect(typo.path).toBe('Text');
+ expect(typo.name).toBe('Body');
+ });
+
test('typography plugin data round-trips', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.setPluginData('k', 'v');
@@ -279,6 +313,21 @@ describe('Library', () => {
expect(comp.path).toBe('Controls');
});
+ test('component name with a path is idempotent', (ctx) => {
+ const rect = ctx.penpot.createRectangle();
+ ctx.board.appendChild(rect);
+ const comp = ctx.penpot.library.local.createComponent([rect]);
+ comp.name = 'Controls/Button';
+ expect(comp.path).toBe('Controls');
+ expect(comp.name).toBe('Button');
+ comp.name = 'Controls/Button';
+ expect(comp.path).toBe('Controls');
+ expect(comp.name).toBe('Button');
+ comp.name = 'Input';
+ expect(comp.path).toBe('Controls');
+ expect(comp.name).toBe('Input');
+ });
+
test('component plugin data round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
@@ -0,0 +1,41 @@
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
index c7895ef7..fda13133 100644
--- a/frontend/src/app/plugins/library.cljs
+++ b/frontend/src/app/plugins/library.cljs
@@ -440,7 +455,7 @@
:lineHeight
{:this true
- :get #(-> % u/proxy->library-typography :font-height)
+ :get #(-> % u/proxy->library-typography :line-height)
:set
(fn [self value]
(cond
@@ -452,7 +467,7 @@
:else
(let [typo (-> (u/proxy->library-typography self)
- (assoc :font-height value))]
+ (assoc :line-height value))]
(st/emit! (dwl/update-typography typo file-id)))))}
:letterSpacing
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
index 32da4f5a..4bb5d4d4 100644
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
@@ -238,9 +267,14 @@ describe('Library', () => {
test('typography applies to a text shape', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.fontSize = '22';
+ // `lineHeight` must be stored under `:line-height` (the key the rest of
+ // the app and the text attrs use), otherwise the applied typography
+ // silently keeps the shape's current line height.
+ typo.lineHeight = '1.5';
const t = text(ctx);
typo.applyToText(t);
expect(t.fontSize).toBe('22');
+ expect(t.lineHeight).toBe('1.5');
});
test('typography applies to a text range', (ctx) => {
+294
View File
@@ -0,0 +1,294 @@
# Penpot's WebAssembly canvas renderer (the upstream `render-wasm/` tree):
# Rust compiled for wasm32-unknown-emscripten with emscripten, statically
# linking Penpot's prebuilt Skia binary distribution.
#
# Packaging notes (why this is not a plain buildRustPackage):
#
# - nixpkgs cannot cross-compile Rust for wasm32-unknown-emscripten: the
# target is absent from `pkgsCross` and nixpkgs' `rustc` ships no std for
# it. The Rust toolchain is therefore assembled from the official dist
# tarballs (version pinned to the one upstream's devenv uses), and
# nixpkgs' emscripten provides emcc/em++/emar. Upstream devenv pins
# emsdk 4.0.6; nixpkgs' emscripten (6.0.8) accepts the same flag set.
# - The skia-bindings build script normally downloads a prebuilt Skia
# tarball from GitHub at build time. We fetch that tarball with fetchurl
# and hand it to the build script via SKIA_BINARIES_URL=file://, so the
# build itself runs sandboxed with no network access.
# - Cargo dependencies are vendored with rustPlatform.fetchCargoVendor
# (itself a fixed-output derivation). When penpot (or its
# render-wasm/Cargo.lock) changes, recompute the hash: set it to
# lib.fakeHash, run `nix build .#penpot-render-wasm`, and copy the
# "got: sha256-..." value from the hash-mismatch error back into this
# file. If the Rust toolchain version (rustVersion below) or the Skia
# tarball URL changes, the corresponding fetchurl hashes must be
# recomputed as well (same procedure: set to lib.fakeHash, read `got:`).
{
lib,
stdenv,
runCommand,
fetchurl,
patchelf,
libgcc,
rustPlatform,
emscripten,
esbuild,
zlib,
penpot,
version,
}:
let
# Rust version pinned by upstream's devenv (1.91.0, released 2025-10-30).
rustVersion = "1.91.0";
rustDistDate = "2025-10-30";
wasmTarget = "wasm32-unknown-emscripten";
# Only the two systems this flake targets are wired up; the official dist
# tarballs are host-specific, hence the per-system hashes.
hostTriple =
{
x86_64-linux = "x86_64-unknown-linux-gnu";
aarch64-linux = "aarch64-unknown-linux-gnu";
}
.${stdenv.hostPlatform.system}
or (throw "penpot-render-wasm: unsupported system ${stdenv.hostPlatform.system}");
rustDistHashes =
{
x86_64-linux = {
rustc = "sha256-pxaejLYXSvL0VxdwM3A2PY3oLOVfbMuhhYkwRbk3CHQ=";
cargo = "sha256-cQPAP7ir6FsjMHAFqd/k8ByCaomUXYS5b6LQP9TS0Tg=";
hostStd = "sha256-ieZSCxbBK0NSZEApjS2g3LcHR8XMLQuOR9ObXamu70k=";
};
aarch64-linux = {
rustc = "sha256-8+o8lkt/O4hDN/LUEXZAMrvRci1/VVkqVHy7Ka/YfAM=";
cargo = "sha256-AD1wCCGcoNIlrR36MB98B5sSNJlDDuB4DIV4Lgh47v8=";
hostStd = "sha256-/yPcgfeW1k406GakT9C8rnJuNAFINTabj5OTpUQWfso=";
};
}
.${stdenv.hostPlatform.system};
rustDist =
name: hash:
fetchurl {
url = "https://static.rust-lang.org/dist/${rustDistDate}/${name}.tar.xz";
inherit hash;
};
# Minimal rustc + cargo installation (laid out like a rustup toolchain):
# std for the host is needed for build scripts and proc-macros, std for
# the emscripten target for the actual build. The dist binaries are
# unpatched upstream builds, so the ELF files get their interpreter and
# rpath pointed at nixpkgs' glibc/libgcc/zlib explicitly (autoPatchelfHook
# proved unreliable for these binaries).
rustToolchain =
runCommand "penpot-render-wasm-rust-toolchain-${rustVersion}"
{
nativeBuildInputs = [ patchelf ];
# The dist binaries ship pre-stripped; stripping them corrupts them.
dontStrip = true;
}
''
mkdir -p $out
tar -xJf ${rustDist "rustc-${rustVersion}-${hostTriple}" rustDistHashes.rustc} \
--strip-components=2 -C $out "rustc-${rustVersion}-${hostTriple}/rustc"
tar -xJf ${rustDist "cargo-${rustVersion}-${hostTriple}" rustDistHashes.cargo} \
--strip-components=2 -C $out "cargo-${rustVersion}-${hostTriple}/cargo"
tar -xJf ${rustDist "rust-std-${rustVersion}-${hostTriple}" rustDistHashes.hostStd} \
--strip-components=2 -C $out "rust-std-${rustVersion}-${hostTriple}/rust-std-${hostTriple}"
tar -xJf ${rustDist "rust-std-${rustVersion}-${wasmTarget}" "sha256-0oJ49jPVYxTJAE058YHy1vz7kdqXSTgOsp9Hzo+Bfk4="} \
--strip-components=2 -C $out "rust-std-${rustVersion}-${wasmTarget}/rust-std-${wasmTarget}"
rpath="$out/lib:${stdenv.cc.libc}/lib:${libgcc}/lib:${zlib}/lib"
interp="$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)"
find $out -type f \( \
-path "*/bin/cargo" -o -path "*/bin/rustc" -o -path "*/bin/rustdoc" \
-o -name "librustc_driver*.so" -o -name "libLLVM.so.*" \
-o -name rust-analyzer-proc-macro-srv -o -path "*rustlib/*/bin/*" \
\) -print0 | while IFS= read -r -d "" f; do
# Skip linker scripts / completions etc. that match the globs.
[ "$(head -c 4 "$f" | od -An -tx1 | tr -d " \n")" = "7f454c46" ] || continue
if [ -n "$(patchelf --print-interpreter "$f" 2>/dev/null)" ]; then
patchelf --set-interpreter "$interp" "$f"
fi
patchelf --set-rpath "$rpath" "$f"
done
# Fail the derivation if the toolchain cannot actually run sandboxed.
$out/bin/cargo --version
$out/bin/rustc --version
'';
# Prebuilt Skia for wasm32/emscripten, pinned by the SKIA_BINARIES_URL
# default in penpot/render-wasm/_build_env. Handed to the skia-bindings
# build script, which accepts file:// URLs for exactly this purpose.
skiaBinaries = fetchurl {
url = "https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-${wasmTarget}-gl-svg-textlayout-binary-cache-webp.tar.gz";
hash = "sha256-kwqI/Cg2c0JrzDKOAnselx+JgSPjhn/y7wS40DpDTx0=";
};
cargoDeps = rustPlatform.fetchCargoVendor {
name = "penpot-render-wasm-cargo-deps";
src = penpot + "/render-wasm";
hash = "sha256-Qxv3feKgQe/0+43rEehoRWibCJoxvOAHFDLY3Sq5jWs=";
};
# Common emcc flags, verbatim from penpot/render-wasm/_build_env, plus
# -sDEFAULT_TO_CXX: the prebuilt Skia libraries are C++ objects that need
# libc++abi at link time, which newer emcc no longer pulls in when the
# linker is invoked as plain emcc (as rustc does).
emccCommonFlags = [
"-sDEFAULT_TO_CXX=1"
"--no-entry"
"--js-library"
"src/js/wapi.js"
"-sMALLOC=dlmalloc"
"-sINVOKE_RUN=0"
"-sALLOW_TABLE_GROWTH=0"
"-sALLOW_MEMORY_GROWTH=1"
"-sINITIAL_HEAP=268435456" # upstream: $((256 * 1024 * 1024))
"-sMEMORY_GROWTH_GEOMETRIC_STEP=0.8"
"-sERROR_ON_UNDEFINED_SYMBOLS=0"
"-sMAX_WEBGL_VERSION=2"
"-sEXPORT_NAME=createRustSkiaModule"
"-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32"
# Upstream 2.17.x builds with -sENVIRONMENT=web; the extra node/worker
# environments are only needed by the (post-2.17) wasm-based exporter.
"-sENVIRONMENT=web"
"-sMODULARIZE=1"
"-sDISABLE_EXCEPTION_CATCHING=1"
"-sFILESYSTEM=0"
"-sEXPORT_ES6=1"
];
# Builds one flavor of the renderer. Upstream drives both flavors through
# render-wasm/build + _build_env; the differences are the cargo profile
# (--release vs --profile size) and the emcc optimization level.
buildRenderWasm =
flavor:
assert lib.assertOneOf "flavor" flavor [
"frontend"
"export"
];
let
isFrontend = flavor == "frontend";
cargoProfileFlag = if isFrontend then "--release" else "--profile size";
profileDir = if isFrontend then "release" else "size";
in
stdenv.mkDerivation {
pname = "penpot-render-wasm" + lib.optionalString (!isFrontend) "-export";
inherit version;
src = penpot + "/render-wasm";
nativeBuildInputs = [
rustToolchain
emscripten
esbuild
];
env = {
CARGO_BUILD_TARGET = wasmTarget;
SKIA_BINARIES_URL = "file://${skiaBinaries}";
EMCC_CFLAGS = lib.concatStringsSep " " (
(if isFrontend then [ "-O3" ] else [ "-Oz" ]) ++ [ "-sASSERTIONS=0" ] ++ emccCommonFlags
);
};
buildPhase = ''
runHook preBuild
export CARGO_TARGET_DIR="$PWD/cargo-target"
export CARGO_HOME="$PWD/cargo-home"
export CC_wasm32_unknown_emscripten=emcc
export AR_wasm32_unknown_emscripten=emar
# emcc needs a writable cache; seed it from the store copy so the
# sysroot libraries are not rebuilt.
export EM_CACHE="$PWD/emcc-cache"
cp -r --reflink=auto "${emscripten}/share/emscripten/cache/." "$EM_CACHE/"
chmod -R u+w "$EM_CACHE/"
# Point cargo at the vendored crates; fetchCargoVendor's config
# ships with a @vendor@ placeholder to fill in.
cp -r --reflink=auto "${cargoDeps}" ./cargo-vendor
chmod -R u+w ./cargo-vendor
mkdir -p .cargo
substitute "${cargoDeps}/.cargo/config.toml" .cargo/config.toml \
--subst-var-by vendor "$PWD/cargo-vendor"
${lib.optionalString (!isFrontend) ''
# Penpot 2.17.2 predates the size-optimized profile that newer
# upstream uses for the exporter flavor; add it (same definition
# as current upstream render-wasm/Cargo.toml).
if ! grep -q "^\[profile\.size\]" Cargo.toml; then
cat >> Cargo.toml <<'PROFILES'
[profile.size]
inherits = "release"
opt-level = "z"
PROFILES
fi
''}
cargo build ${cargoProfileFlag} --offline
runHook postBuild
'';
installPhase = ''
runHook preInstall
artifacts="$PWD/cargo-target/${wasmTarget}/${profileDir}"
mkdir -p "$out"${lib.optionalString isFrontend "/worker"}
cp "$artifacts/render_wasm.js" "$out/render-wasm.js"
cp "$artifacts/render_wasm.wasm" "$out/render-wasm.wasm"
# Match upstream copy_artifacts(): rename the wasm reference and add
# a version query for cache busting.
sed -i "s/render_wasm\.wasm/render-wasm.wasm?version=${version}/g" \
"$out/render-wasm.js"
shared="$(find "$PWD/cargo-target/${wasmTarget}" -name render_wasm_shared.js | head -n 1)"
cp "$shared" "$out/shared.js"
${lib.optionalString isFrontend ''
# Worker bundle, exactly as upstream copy_artifacts() builds it
# (the exporter imports the ESM module directly instead).
esbuild "$artifacts/render_wasm.js" \
--log-level=error \
--outfile="$out/worker/render.js" \
--platform=neutral \
--format=iife \
--global-name=WasmModule
''}
# Fail loudly instead of shipping an empty/broken artifact.
test -s "$out/render-wasm.js"
test -s "$out/render-wasm.wasm"
test -s "$out/shared.js"
runHook postInstall
'';
meta = {
description =
if isFrontend then
"Penpot WebAssembly canvas renderer (frontend flavor)"
else
"Penpot WebAssembly canvas renderer (exporter flavor)";
homepage = "https://penpot.app";
license = lib.licenses.mpl20;
platforms = lib.platforms.linux;
};
};
in
let
frontend = buildRenderWasm "frontend";
in
frontend
// {
passthru = frontend.passthru or { } // {
export = buildRenderWasm "export";
};
}
+61
View File
@@ -0,0 +1,61 @@
# Common services.penpot configuration shared by the demo VM and the VM test.
#
# NOTE: the public URI must match the URL the stack is actually reached on.
# The demo VM forwards host:8080 -> guest:8080, so browsing
# http://localhost:8080 and calling the API on http://localhost would be a
# cross-origin mismatch (every request would need a failing preflight).
{
lib,
pkgs,
...
}:
{
services.penpot = {
enable = true;
publicUri = "http://localhost:8080";
mailpit.enable = true;
# Registration always creates NON-admin users; admins are granted by
# email at login time (checked against PENPOT_ADMINS on every request).
# This demo grants admin to the pre-registered demo account below,
# replace it with the email you register with, then rebuild and log
# out/in for it to take effect.
admins = [ "admin@penpot.local" ];
# Demo/test-only secret key. Production deployments must supply their
# own file (e.g. via sops-nix) instead of a store path.
secretKeyFile = pkgs.writeText "penpot-demo-secret-key" "penpot-demo-secret-key";
# Demo/test-only passwords exercising the password-credential paths
# (see the LoadCredential asserts in vm-test.nix). With
# database.passwordFile set, the local connection uses md5 auth with
# the provisioned role password instead of the scoped trust rule.
database.passwordFile = pkgs.writeText "penpot-demo-db-password" "penpot-demo-db-password";
smtp.username = "penpot-demo";
smtp.passwordFile = pkgs.writeText "penpot-demo-smtp-password" "penpot-demo-smtp-password";
# Evaluation defaults mirroring the upstream docker-compose file: skip
# the email-confirmation roundtrip on registration (the demo SMTP sink
# still works and is reachable from the host, see below).
flags = [
"enable-prepl-server"
"disable-email-verification"
];
# Keep the backend's JVM footprint predictable in the memory-constrained
# test/demo VM.
jvmOpts = [
"-Xms256m"
"-Xmx1g"
];
};
# Demo only: expose the mailpit UI beyond loopback so the host can browse
# it (http://localhost:8025) and read the emails the instance sends.
services.mailpit.instances.penpot.listen = "0.0.0.0:8025";
networking.firewall.allowedTCPPorts = [
8080
8025
];
}
+156
View File
@@ -0,0 +1,156 @@
{
lib,
self,
...
}:
{
name = "penpot";
meta.maintainers = with lib.maintainers; [ ];
nodes.machine =
{ lib, ... }:
{
imports = [
{ nixpkgs.overlays = [ self.overlays.default ]; }
self.nixosModules.default
./penpot-config.nix
];
# Room for the backend JVM, the exporter's Chromium pool and
# PostgreSQL during boot.
virtualisation.memorySize = 3072;
virtualisation.diskSize = 4096;
};
testScript = ''
start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_unit("redis-penpot.service")
machine.wait_for_unit("mailpit-penpot.service")
machine.wait_for_unit("nginx.service")
machine.wait_for_unit("penpot-backend.service")
def http(url, extra="", fail=True):
"""Fetch url with curl and return the response body or headers.
fail=False keeps going on HTTP error statuses, for endpoints that
answer with a useful body on them (e.g. the MCP 406)."""
flag = "-f " if fail else ""
return machine.succeed("curl -sS " + flag + extra + " " + url)
def http_code(url, extra=""):
return machine.succeed(
f"curl -sS -o /dev/null -w '%{{http_code}}' {extra} {url}"
).strip()
mcp_init = (
"-X POST -H 'Content-Type: application/json' "
"-d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}'"
)
# The backend is a source-run Clojure uberjar: give it a generous window
# to reach the readyz endpoint (which also exercises the database).
machine.wait_until_succeeds(
"curl -fsS http://localhost:8080/readyz -o /tmp/readyz && grep -q OK /tmp/readyz",
timeout=900,
)
# Provisioning and secret handling.
machine.succeed("systemctl is-active penpot-db-setup.service")
dbowner = machine.succeed(
"su postgres -c \"psql -tAc \\\"SELECT pg_get_userbyid(datdba)"
" FROM pg_database WHERE datname='penpot'\\\"\""
)
assert "penpot" in dbowner
machine.succeed("test -d /var/lib/penpot/assets")
# Frontend static site and rendered configuration.
assert "<!doctype html" in http("http://localhost:8080/").lower()
config_js = http("http://localhost:8080/js/config.js")
for needle in ("penpotPublicURI", "disable-secure-session-cookies", "enable-smtp"):
assert needle in config_js
assert "no-store" in http("http://localhost:8080/js/config.js", "-I").lower()
assert len(http("http://localhost:8080/js/worker/main.js")) > 0
assert "max-age=604800" in http("http://localhost:8080/js/worker/main.js", "-I")
# Exporter and MCP entry points.
machine.wait_for_unit("penpot-exporter.service")
machine.wait_until_succeeds(
"curl -fsS http://127.0.0.1:6061/readyz -o /tmp/exporter-readyz"
" && grep -q OK /tmp/exporter-readyz",
timeout=120,
)
machine.wait_for_unit("penpot-mcp.service")
machine.wait_for_open_port(4401)
# In multi-user mode a tokenless initialize is answered with a JSON-RPC
# error (HTTP 406); any well-formed JSON-RPC response proves the server
# is serving the MCP protocol.
assert "jsonrpc" in http("http://127.0.0.1:4401/mcp", mcp_init, fail=False)
# The vhost routes /api/export to the exporter: the status code via the
# vhost equals the one from hitting the exporter directly (the exporter
# answers unauthenticated GETs with its own validation error), proving
# the proxy hop without hardcoding a status.
assert http_code("http://localhost:8080/api/export/readyz") == http_code(
"http://127.0.0.1:6061/api/export/readyz"
)
assert "jsonrpc" in http("http://localhost:8080/mcp/stream", mcp_init, fail=False)
# Administrative CLI bootstrap path (no email roundtrip): the
# penpot-manage tool ships on the machine PATH and talks to the backend
# prepl server; profiles created through it are active immediately.
machine.succeed(
"penpot-manage create-profile -n 'CLI Admin'"
" -e cli@penpot.local -p cli-password-123"
)
cli_login = http(
"http://localhost:8080/api/rpc/command/login-with-password",
"-X POST -H 'Content-Type: application/transit+json' "
+ "-d '[\"^ \",\"~:email\",\"cli@penpot.local\","
+ "\"~:password\",\"cli-password-123\"]'",
fail=False,
)
assert "cli@penpot.local" in cli_login
assert '"~:is-admin",false' in cli_login
# Real API roundtrip (nginx -> backend -> database): an unknown user
# login gets the backend's own validation error back.
assert "wrong-credentials" in http(
"http://localhost:8080/api/rpc/command/login-with-password",
"-X POST -H 'Content-Type: application/transit+json' "
+ "-d '[\"^ \",\"~:email\",\"probe@example.org\","
+ "\"~:password\",\"wrong-password-123\"]'",
fail=False,
)
# Websocket proxying path answers (backend not fully upgraded without a
# client, but the vhost must not 404 on it).
assert http_code(
"http://localhost:8080/ws/notifications",
"-H 'Upgrade: websocket' -H 'Connection: Upgrade'",
) in ("426", "400", "101")
# SMTP sink: deliver a mail through mailpit via curl's SMTP client and
# read it back through the mailpit API.
machine.succeed(
"printf 'Subject: penpot-test\\r\\nFrom: test@example.org\\r\\nTo:"
" test@example.org\\r\\n\\r\\nhello from the test\\r\\n'"
" | curl -sS smtp://127.0.0.1:1025"
" --mail-from test@example.org --mail-rcpt test@example.org -T -"
)
machine.wait_until_succeeds(
"curl -fsS 'http://127.0.0.1:8025/api/v1/messages' -o /tmp/mailpit"
" && grep -q penpot-test /tmp/mailpit",
timeout=60,
)
# Restart semantics: the backend must come back to a healthy state
# after a restart.
machine.succeed("systemctl restart penpot-backend.service")
machine.wait_until_succeeds(
"curl -fsS http://localhost:8080/readyz -o /tmp/readyz2 && grep -q OK /tmp/readyz2",
timeout=900,
)
'';
}
+58
View File
@@ -0,0 +1,58 @@
# A minimal headless NixOS VM running a full Penpot stack (backend, nginx
# vhost, exporter, MCP, PostgreSQL, Valkey and the mailpit SMTP sink).
#
# Build it with:
# nix build .#nixosConfigurations.penpot-vm.config.system.build.vm
# and run it headless with:
# ./result/bin/run-penpot-vm-vm
#
# The guest's port 8080 (the vhost's listen port, matching the public URI)
# is forwarded to the host's port 8080, so once the VM is up you can verify
# the deployment from the host:
# curl -fsS http://127.0.0.1:8080/readyz # backend health (via nginx)
# curl -fsS http://127.0.0.1:8080/ # frontend
# curl -fsS http://127.0.0.1:8080/js/config.js
# The mailpit SMTP-sink UI is forwarded too: http://localhost:8025
{
lib,
modulesPath,
...
}:
{
imports = [
"${modulesPath}/virtualisation/qemu-vm.nix"
./penpot-config.nix
];
networking.hostName = "penpot-vm";
# Headless: serial console only.
virtualisation.graphics = false;
virtualisation.qemu.consoles = [ "ttyS0" ];
boot.kernelParams = [ "console=ttyS0,115200" ];
# The Clojure backend plus Chromium-driven exporter need some headroom.
virtualisation.memorySize = 3072;
virtualisation.diskSize = 4096;
# Host and guest ports must match: the vhost listens on the public URI's
# port (8080) and the served config.js points the app at that URL.
virtualisation.forwardPorts = [
{
from = "host";
host.port = 8080;
guest.port = 8080;
}
{
from = "host";
host.port = 8025;
guest.port = 8025;
}
];
system.stateVersion = "25.11";
# Convenience for poking around over the serial console.
services.getty.autologinUser = "root";
users.users.root.initialHashedPassword = "";
}