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

+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";
};
}