Merge remote-tracking branch 'origin/staging'

This commit is contained in:
Andrey Antukh 2026-07-10 11:35:04 +02:00
commit 3add7211a5
47 changed files with 887 additions and 615 deletions

View File

@ -6,26 +6,20 @@ description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-ev
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
`tools/nrepl-eval.mjs`.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`)
## Usage
## Quick Reference
```bash
node tools/nrepl-eval.mjs [options] [<code>]
```
The tool is also executable directly:
```bash
./tools/nrepl-eval.mjs [options] [<code>]
```
## Options
| Flag | Description | Default |
|------|-------------|---------|
| `--backend` | Connect to backend nREPL (port 6064) | — |
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
| `-p, --port PORT` | nREPL server port | `6064` |
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
@ -33,88 +27,11 @@ The tool is also executable directly:
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
| `-h, --help` | Show help message | — |
## When to Use
## Examples
Use this tool when you need to:
1. **Evaluate Clojure code** during development — test functions, inspect
state, or run experiments against a running Clojure process.
2. **Verify that edited files compile** — require namespaces with `:reload`
to pick up changes.
3. **Inspect the last exception** after a failed evaluation — use `-e` to
print the error stored in `*e`.
## Workflow
### 1. Session management
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
```
### 2. Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### 3. Inspect last exception
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./tools/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when
your nREPL server is on 6064. Use `-p <PORT>` for a different port.
- **Always use `:reload`** when requiring namespaces to pick up file changes.
- **Session is reused** across invocations — defs, in-ns, and var bindings
persist. Use `--reset-session` to clear.
- **Do not start any server** — the tool connects to an existing nREPL
server, it is not the agent's responsibility to start the nREPL server
(assume the server is already running on the specified port).

View File

@ -106,3 +106,4 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`.

View File

@ -49,6 +49,7 @@ Components, variants, and debugging:
Text and tests:
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`.
## Areas without focused memories

View File

@ -0,0 +1,47 @@
# Testing Principles (Cross-Cutting)
Shared testing principles for all modules (backend, frontend, common, render-wasm, plugins). Module-specific commands and helpers are in each module's own testing memory.
## Core Principles
- **Test State, Not Interactions** — assert on outcomes, not method calls; survives refactoring
- **DAMP over DRY** — tests are specifications; duplication OK if each test is self-contained and readable
- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; mock only at boundaries (network, filesystem, email)
- **Arrange-Act-Assert** — every test: setup / action / verify
- **One Assertion Per Concept** — each test verifies one behavior; split compound assertions
- **Descriptive Test Names** — names read like specifications
## Anti-Patterns
- Testing implementation details → breaks on refactor
- Flaky tests (timing, order-dependent) → erode trust
- Mocking everything → tests pass, production breaks
- No test isolation → pass individually, fail together
- Testing framework code → waste of time
- Snapshot abuse → nobody reviews, break on any change
## Verification Checklist
After completing any implementation:
- Every new behavior has a corresponding test
- All tests pass for touched modules
- Bug fixes include a reproduction test that failed before the fix
- Test names describe the behavior being verified
- No tests were skipped or disabled
## Red Flags
- Writing code without corresponding tests
- Tests that pass on the first run (may not be testing what you think)
- Bug fixes without reproduction tests
- Tests that test framework behavior instead of app behavior
- Skipping tests to make the suite pass
## Rationalizations
- "I'll write tests after" → you won't; they'll test implementation not behavior
- "Too simple to test" → simple gets complicated; test documents expected behavior
- "Tests slow me down" → they speed up every future change
- "I tested manually" → manual testing doesn't persist
- "Code is self-explanatory" → tests ARE the specification
- "Just a prototype" → prototypes become production; test debt accumulates

View File

@ -48,13 +48,24 @@ module. You can read it from `mem:<MODULE>/core`
When working on devenv startup, compose layout, instance config (`defaults.env`),
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
`*-devenv` commands, read `mem:devenv/core`.
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`),
`psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and
`taiga.py` / `gh.py` (issue management helpers).
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
# Dev tools
- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
See `mem:tools/nrepl-eval`.
- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
See `mem:tools/paren-repair`.
- `tools/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`.
- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the
Penpot Taiga project without authentication. See `mem:tools/taiga`.
- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`.
# Dependency graph
`frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can

View File

@ -52,6 +52,7 @@ Diagnostics and validation:
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
- Runtime crash recovery: `mem:frontend/handling-crashes`.
- Tests and live verification: `mem:frontend/testing`.
- Cross-cutting testing principles and anti-patterns: `mem:common/testing-principles`.
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
## Areas without focused memories

View File

@ -0,0 +1,80 @@
# GitHub operations helper
`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
repository via GraphQL and REST APIs through the authenticated `gh` CLI.
## When to use
- Listing issues in a milestone (for changelog generation).
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`).
- Python 3.8+.
## Subcommands
### `issues`
List issues in a milestone, with filtering by state, labels, and project status.
```bash
# Closed issues in a milestone (default)
python3 tools/gh.py issues "2.16.0"
# All issues in a milestone
python3 tools/gh.py issues "2.16.0" --state all
# Issues with no milestone
python3 tools/gh.py issues none
python3 tools/gh.py issues none --state open
# Filter by label (include only)
python3 tools/gh.py issues "2.16.0" --label "bug"
python3 tools/gh.py issues "2.16.0" --label "bug,regression"
# Exclude by label
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
# Show only issues NOT yet in CHANGES.md
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
```
**Default filters** (override with flags):
- Issues with type "Task" are excluded (`--include-tasks` to keep them).
- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them).
**Output**: JSON array to stdout; progress to stderr.
### `prs`
Fetch PR details by number or by milestone.
```bash
# Fetch specific PRs
python3 tools/gh.py prs 9179 9204 9311
# Read PR numbers from file
python3 tools/gh.py prs --file prs.txt
# Read PR numbers from stdin
cat prs.txt | python3 tools/gh.py prs --stdin
# All PRs in a milestone (default: merged only)
python3 tools/gh.py prs --milestone "2.16.0"
# All PRs in a milestone (all states)
python3 tools/gh.py prs --milestone "2.16.0" --state all
```
**Output**: JSON array to stdout; progress to stderr.
## Key principles
- All output is JSON — pipe into `jq` or other tools for further processing.
- Milestone lookup is by exact title match.
- `issues` subcommand auto-paginates (100 items per page).
- `prs` subcommand batches PR number lookups (50 per GraphQL query).

View File

@ -0,0 +1,126 @@
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
## Usage
```bash
node tools/nrepl-eval.mjs [options] [<code>]
# or
./tools/nrepl-eval.mjs [options] [<code>]
```
## Options
| Flag | Description | Default |
|------|-------------|---------|
| `--backend` | Connect to backend nREPL (port 6064) | — |
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
| `-p, --port PORT` | nREPL server port | `6064` |
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
| `--reset-session` | Discard stored session and start fresh | — |
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
| `-h, --help` | Show help message | — |
- `--backend` and `--frontend` are mutually exclusive.
- Explicit `--port` is overridden when `--backend`/`--frontend` is used.
## When to Use
1. **Evaluate Clojure code** during development — test functions, inspect
state, or run experiments against a running Clojure process.
2. **Verify that edited files compile** — require namespaces with `:reload`
to pick up changes.
3. **Inspect the last exception** after a failed evaluation — use `-e` to
print the error stored in `*e`.
## Workflow
### Session management
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
```
### Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
```
**Backend nREPL (explicit):**
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
```
**Frontend nREPL:**
```bash
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### Inspect last exception
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when
your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447)
as quick aliases. Use `-p <PORT>` for any other port.
- **Always use `:reload`** when requiring namespaces to pick up file changes.
- **Session is reused** across invocations — defs, in-ns, and var bindings
persist. Use `--reset-session` to clear.
- **Do not start any server** — the tool connects to an existing nREPL
server, it is not the agent's responsibility to start the nREPL server
(assume the server is already running on the specified port).

View File

@ -0,0 +1,44 @@
# Taiga API client
`tools/taiga.py` fetches public issues, user stories, and tasks from the
Penpot Taiga project (id 345963) without authentication.
## When to use
- Fetching details of a Taiga issue, user story, or task by URL or ref number.
- Inspecting status, assignee, tags, description, and other metadata.
- Piping structured JSON into other scripts (with `--json`).
## How to use
```bash
# Fetch by full Taiga URL
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Fetch by type and ref number
python3 tools/taiga.py issue 13714
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
# Output raw JSON instead of formatted summary
python3 tools/taiga.py --json issue 13714
python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
```
## Supported types
| Type | Description |
|------|-------------|
| `issue` | Bug reports, feature requests |
| `us` | User stories |
| `task` | Implementation tasks |
## Output
Default output is a formatted summary with title, status, assignee, author,
tags, URL, and description. Use `--json` for the raw API response.
## Prerequisites
- Python 3.8+ (standard library only, no dependencies).
- Network access to `api.taiga.io`.

View File

@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Bug report (steps to reproduce, expected vs. actual) → Bug
- Enhancement / new feature → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task

View File

@ -2,6 +2,20 @@
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
## Metadata
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
## Title Format
PR titles follow commit title conventions:
@ -24,7 +38,7 @@ Include concise sections covering:
PR descriptions follow this structure:
```markdown
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
**Note:** This PR was created with AI assistance.
## What
@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- Follow `mem:workflow/creating-commits` for commits
- Run the focused tests/lints appropriate to touched modules.
- Do not force-push during review unless the maintainer workflow explicitly asks for it.
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
## Creating the PR
```bash
cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
gh pr create \
--repo penpot/penpot \
--base "$TARGET" \
--head <branch> \
--title "<title>" \
--project "Main" \
--body-file /tmp/pr-body.md
rm -f /tmp/pr-body.md
```

View File

@ -331,7 +331,10 @@
This expands to a single SQL statement with placeholders for every
value being inserted. For large data sets, this may exceed the limit
of sql string size and/or number of parameters."
of sql string size and/or number of parameters.
See `insert-many-chunked!` for a safe alternative that automatically
partitions rows to stay within the parameter limit."
[ds table cols rows & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/insert-many table cols rows opts)
@ -341,6 +344,24 @@
opts (update opts :return-keys boolean)]
(jdbc/execute! conn sql opts)))
(def ^:private default-max-params
"PostgreSQL PreparedStatement parameter limit."
65535)
(defn insert-many-chunked!
"Like `insert-many!` but partitions rows into chunks that stay within
PostgreSQL's 65,535 PreparedStatement parameter limit.
The chunk size is computed as `floor(max-params / num-columns)`,
so callers do not need to calculate it. All chunks execute within
the same transaction when called inside `tx-run!`."
[ds table cols rows & {:keys [max-params] :as opts
:or {max-params default-max-params}}]
(let [chunk-size (quot max-params (count cols))
opts (dissoc opts :max-params)]
(doseq [chunk (partition-all chunk-size rows)]
(apply insert-many! ds table cols chunk (mapcat identity opts)))))
(defn update!
"A helper that build an UPDATE SQL statement and executes it.

View File

@ -65,7 +65,6 @@
(assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd
(seq prlimit)
(into (prlimit-cmd prlimit)))
@ -74,6 +73,9 @@
_ (reduce-kv set-env env-map env)
process (.start builder)]
(when-not executor
(throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor")))
(if in
(px/run! executor
(fn []

View File

@ -8,12 +8,17 @@
(:require
[app.common.exceptions :as ex]
[app.util.shell :as shell]
[app.worker :as-alias wrk]
[clojure.string :as str]
[clojure.test :as t]))
[clojure.test :as t]
[promesa.exec :as px]))
(def ^:private system
{::wrk/executor (px/cached-executor)})
(t/deftest exec-normal-completes
(t/testing "normal process completes within timeout"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -21,7 +26,7 @@
(t/deftest exec-captures-stderr
(t/testing "stderr is captured separately"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo out; echo err >&2"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -30,14 +35,14 @@
(t/deftest exec-non-zero-exit
(t/testing "non-zero exit code is captured"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "exit 42"]
:timeout 10)]
(t/is (= 42 (:exit result))))))
(t/deftest exec-with-env
(t/testing "environment variables are passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo $MY_VAR"]
:env {"MY_VAR" "test-value"}
:timeout 10)]
@ -46,7 +51,7 @@
(t/deftest exec-with-input
(t/testing "stdin input is passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["cat"]
:in "hello from stdin"
:timeout 10)]
@ -57,7 +62,7 @@
(t/testing "process that exceeds timeout is killed and raises exception"
(let [start (System/currentTimeMillis)]
(try
(shell/exec! {}
(shell/exec! system
:cmd ["sleep" "60"]
:timeout 1)
(t/is false "should have thrown")
@ -72,14 +77,14 @@
(t/deftest exec-no-timeout-waits
(t/testing "without timeout, process runs to completion"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["sleep" "0.1"]
:timeout nil)]
(t/is (= 0 (:exit result))))))
(t/deftest exec-prlimit-normal
(t/testing "normal process completes within prlimit"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:prlimit {:mem 256 :cpu 10}
:timeout 10)]
@ -88,7 +93,7 @@
(t/deftest exec-prlimit-cpu
(t/testing "process exceeding CPU limit is killed"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "while true; do :; done"]
:prlimit {:cpu 2}
:timeout 10)]
@ -98,7 +103,7 @@
(t/testing "process exceeding memory limit is killed"
;; Use python3 to allocate more memory than the limit allows.
;; This test requires python3 to be available in the environment.
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["python3" "-c"
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
:prlimit {:mem 256}

View File

@ -500,9 +500,9 @@
shape-transform (:transform shape)
shape-transform-inv (:transform-inverse shape)
shape-center (gco/shape->center shape)
{sr-width :width sr-height :height} (:selrect shape)
{sr-width :width sr-height :height} (safe-size-rect shape)
origin (cond-> (gpt/point (:selrect shape))
origin (cond-> (gpt/point (safe-size-rect shape))
(some? shape-transform)
(gmt/transform-point-center shape-center shape-transform))

View File

@ -392,7 +392,9 @@
([shape]
(convert-to-path shape {}))
([shape objects]
(-> (stp/convert-to-path shape objects)
(update :content impl/path-data))))
(let [shape' (stp/convert-to-path shape objects)]
(if (identical? shape shape')
shape'
(update shape' :content impl/path-data)))))
(dm/export impl/decode-segments)

View File

@ -657,3 +657,35 @@
mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45)
result (ctm/apply-structure-modifiers shape mods)]
(t/is (mth/close? 45.0 (:rotation result))))))
;; ─── change-orientation-modifiers — degenerate selrect ────────────────────────
(defn- make-degenerate-shape
"Build a shape whose selrect has zero width/height, simulating a shape
decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)."
[x y selrect-width selrect-height]
(let [shape (make-shape x y 100 50)]
(assoc shape :selrect (grc/map->Rect {:x x :y y
:width selrect-width
:height selrect-height
:x1 x :y1 y
:x2 (+ x selrect-width)
:y2 (+ y selrect-height)}))))
(t/deftest change-orientation-zero-width-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-shape 0 0 0 50)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-height-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-shape 0 0 100 0)
mods (ctm/change-orientation-modifiers shape :vert)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw
(t/testing "orientation change on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-shape 0 0 0 0)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))

View File

@ -1370,6 +1370,15 @@
;; A path shape stays a path shape unchanged
(t/is (= :path (:type result)))))
(t/deftest shape-to-path-svg-raw-does-not-throw
(let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0
:selrect (make-selrect 0.0 0.0 100.0 50.0)
:content {:tag :text :attrs {:style {}}
:content [{:tag :tspan :attrs {} :content ["x"]}]}}
result (path/convert-to-path shape {})]
(t/is (= :svg-raw (:type result)))
(t/is (some? (:content result)))))
(t/deftest shape-to-path-rect-with-radius
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0
:r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0

View File

@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13
ENV OPENCODE_VERSION=1.17.16
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -197,10 +197,10 @@ services:
- penpot
environment:
<< : [*penpot-secret-key]
<< : [*penpot-secret-key, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0

View File

@ -651,6 +651,21 @@ PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061
These variables are used for generate correct nginx.conf file on container startup.
### Exporter
The exporter uses this variable:
```bash
# Exporter
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
```
- `PENPOT_INTERNAL_URI`: The URI used by the exporter's headless browser to
communicate with the frontend (internal Docker network). Defaults to
`PENPOT_PUBLIC_URI` if not set. The default value
`http://penpot-frontend:8080` used in the docker-compose is a good default and
it is recommended to keep it unchanged.
## Other flags
There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant

View File

@ -22,6 +22,7 @@
(def ^:private defaults
{:public-uri "http://localhost:3449"
:internal-uri nil
:tenant "default"
:host "localhost"
:http-server-port 6061
@ -33,6 +34,7 @@
[:map {:title "config"}
[:secret-key :string]
[:public-uri {:optional true} ::sm/uri]
[:internal-uri {:optional true} ::sm/uri]
[:exporter-shared-key {:optional true} :string]
[:host {:optional true} :string]
[:tenant {:optional true} :string]
@ -100,6 +102,12 @@
([key default]
(c/get config key default)))
(defn get-internal-uri
"Returns internal-uri if set, otherwise falls back to public-uri."
[]
(or (c/get config :internal-uri)
(c/get config :public-uri)))
(def management-key
(let [key (or (c/get config :exporter-shared-key)
(let [secret-key (c/get config :secret-key)

View File

@ -21,6 +21,7 @@
[& _]
(l/info :msg "initializing"
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(p/do!
(bwr/init)

View File

@ -79,7 +79,7 @@
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]

View File

@ -62,7 +62,7 @@
:skip-children skip-children
:wasm (when is-wasm "true")
:scale scale}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))]

View File

@ -77,7 +77,7 @@
(on-object (assoc object :path path))
(p/recur (rest objects))))))]
(let [base-uri (-> (cf/get :public-uri)
(let [base-uri (-> (cf/get-internal-uri)
(u/ensure-path-slash))]
(bw/exec! (prepare-options base-uri)
(partial render base-uri)))))

View File

@ -108,6 +108,18 @@
{:width width
:height height}))
(defn- replace-internal-uris
"Replaces internal-uri references with public-uri in SVG output.
This ensures that font URLs and other resource references in the
exported SVG use the public-facing URI accessible to end users."
[svg-content]
(let [internal-uri (str (cf/get-internal-uri))
public-uri (str (cf/get :public-uri))]
(if (and (not= internal-uri public-uri)
(str/includes? svg-content internal-uri))
(str/replace svg-content internal-uri public-uri)
svg-content)))
(defn render
[{:keys [page-id file-id share-id objects token scale type]} on-object]
(letfn [(convert-to-ppm [pngpath]
@ -320,7 +332,9 @@
result (if (contains? cf/flags :exporter-svgo)
(svgo/optimize result svgo/defaultOptions)
result)]
result)
result (replace-internal-uris result)]
;; (println "------- ORIGIN:")
;; (cljs.pprint/pprint (xml->clj xmldata))
@ -349,7 +363,7 @@
:render-embed true
:object-id (mapv :id objects)
:route "objects"}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))]

View File

@ -8,7 +8,7 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35",
"immutable": "^5.1.9"
},
"peerDependencies": {

View File

@ -1,449 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
immutable:
specifier: ^5.1.4
version: 5.1.4
react:
specifier: '>=0.17.0'
version: 19.2.3
react-dom:
specifier: '>=0.17.0'
version: 19.2.3(react@19.2.3)
devDependencies:
esbuild:
specifier: ^0.27.2
version: 0.27.2
packages:
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
react-dom: '>=0.14.0'
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
fbjs-css-vars@1.0.2:
resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==}
fbjs@3.0.5:
resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==}
immutable@3.7.6:
resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
engines: {node: '>=0.8.0'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
asap@2.0.6: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
fbjs: 3.0.5
immutable: 3.7.6
object-assign: 4.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- encoding
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
fbjs-css-vars@1.0.2: {}
fbjs@3.0.5:
dependencies:
cross-fetch: 3.2.0
fbjs-css-vars: 1.0.2
loose-envify: 1.4.0
object-assign: 4.1.1
promise: 7.3.1
setimmediate: 1.0.5
ua-parser-js: 1.0.41
transitivePeerDependencies:
- encoding
immutable@3.7.6: {}
immutable@5.1.4: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-assign@4.1.1: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react@19.2.3: {}
scheduler@0.27.0: {}
setimmediate@1.0.5: {}
tr46@0.0.3: {}
ua-parser-js@1.0.41: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1

View File

@ -263,8 +263,8 @@ importers:
packages/draft-js:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35
version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
immutable:
specifier: ^5.1.9
version: 5.1.9
@ -2886,8 +2886,8 @@ packages:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35:
resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
@ -8117,7 +8117,7 @@ snapshots:
domelementtype: 3.0.0
domhandler: 6.0.1
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
fbjs: 3.0.5(encoding@0.1.13)
immutable: 3.8.3

View File

@ -24,7 +24,6 @@
[app.main.data.workspace.drawing :as dwd]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.zoom :as dwz]
[app.main.repo :as rp]
[app.main.router :as rt]
@ -71,7 +70,7 @@
(rx/take-until stopper-s))))))
(defn- handle-interrupt
(defn handle-interrupt
[]
(ptk/reify ::handle-interrupt
ptk/WatchEvent
@ -85,8 +84,7 @@
;; tool is active. When comments are merely visible during design,
;; `select-shape` emits `:interrupt` and this would otherwise wipe
;; the freshly selected shape, breaking click selection.
comments-mode? (rx/of (dwe/clear-edition-mode)
(dws/deselect-all true))
comments-mode? (rx/of (dwe/clear-edition-mode))
:else (rx/empty))))))
;; Event responsible of the what should be executed when user clicked

View File

@ -256,8 +256,8 @@
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
(gpt/point (:selrect shape)))
(let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape))
(gpt/point (ctm/safe-size-rect shape)))
position-data
(-> position-data
(gsh/move-position-data deltav))]

View File

@ -150,7 +150,7 @@
.separator {
margin: 0;
block-size: $sz-12;
border-block-start: $b-1 solid var(--color-background-primary);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
&[data-direction="up"] {

View File

@ -35,8 +35,8 @@
(if-let [modifiers (:modifiers shape)]
(let [shape' (gsh/transform-shape shape modifiers)
old-sr (dm/get-prop shape :selrect)
new-sr (dm/get-prop shape' :selrect)
old-sr (ctm/safe-size-rect shape)
new-sr (ctm/safe-size-rect shape')
;; We need to remove the movement because the dynamic modifiers will have move it
deltav (gpt/to-vec (gpt/point new-sr)

View File

@ -522,7 +522,7 @@
[:*
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector props-first)]
(let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties)
(let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties)
options (get-options (:name prop))
boolean-pair (ctv/find-boolean-pair (mapv :id options))
options (cond-> options

View File

@ -171,12 +171,20 @@
on-caps-start-change
(mf/use-fn
(mf/deps index on-stroke-cap-start-change)
#(on-stroke-cap-start-change index (keyword %)))
(fn [cap]
(let [cap (if (= cap "none")
nil
(keyword cap))]
(on-stroke-cap-start-change index cap))))
on-caps-end-change
(mf/use-fn
(mf/deps index on-stroke-cap-end-change)
#(on-stroke-cap-end-change index (keyword %)))
(fn [cap]
(let [cap (if (= cap "none")
nil
(keyword cap))]
(on-stroke-cap-end-change index cap))))
on-detach-token-color
(mf/use-fn
@ -191,16 +199,16 @@
(on-detach-token token #{:stroke-width})))
stroke-caps-options
[{:value nil :label (tr "workspace.options.stroke-cap.none")}
:separator
{:value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon :stroke-arrow}
{:value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon :stroke-triangle}
{:value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon :stroke-rectangle}
{:value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon :stroke-circle}
{:value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon :stroke-diamond}
:separator
{:value :round :label (tr "workspace.options.stroke-cap.round") :icon :stroke-rounded}
{:value :square :label (tr "workspace.options.stroke-cap.square") :icon :stroke-squared}]
[{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")}
{:label "" :type :separator :id "separator"}
{:id "line-arrow" :value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon i/stroke-arrow}
{:id "triangle-arrow" :value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon i/stroke-triangle}
{:id "square-marker" :value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon i/stroke-rectangle}
{:id "circle-marker" :value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon i/stroke-circle}
{:id "diamond-marker" :value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon i/stroke-diamond}
{:label "" :type :separator :id "separator"}
{:id "round" :value :round :label (tr "workspace.options.stroke-cap.round") :icon i/stroke-rounded}
{:id "square" :value :square :label (tr "workspace.options.stroke-cap.square") :icon i/stroke-squared}]
on-cap-switch
(mf/use-fn
@ -340,16 +348,18 @@
;; Stroke Caps
(when show-caps
[:div {:class (stl/css :stroke-caps-options)}
[:& select {:default-value (:stroke-cap-start stroke)
:options stroke-caps-options
:disabled hidden?
:on-change on-caps-start-change}]
[:> select* {:default-selected (or (d/name (:stroke-cap-start stroke)) "none")
:options stroke-caps-options
:data-testid "stroke.cap-start"
:disabled hidden?
:on-change on-caps-start-change}]
[:> icon-button* {:variant "secondary"
:aria-label (tr "labels.switch")
:disabled hidden?
:on-click on-cap-switch
:icon i/switch}]
[:& select {:default-value (:stroke-cap-end stroke)
:options stroke-caps-options
:disabled hidden?
:on-change on-caps-end-change}]])]))
[:> select* {:default-selected (or (d/name (:stroke-cap-end stroke)) "none")
:options stroke-caps-options
:data-testid "stroke.cap-end"
:disabled hidden?
:on-change on-caps-end-change}]])]))

View File

@ -1059,6 +1059,9 @@
:setPluginData
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/not-valid plugin-id :setPluginData-key key)
@ -1092,6 +1095,9 @@
:setSharedPluginData
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)

View File

@ -272,13 +272,13 @@
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
:getSharedPluginDataKeys
(fn [self namespace]
(fn [namespace]
(cond
(not (string? namespace))
(u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else
(let [page (u/proxy->page self)]
(let [page (u/locate-page file-id id)]
(apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)]))))))
:openPage

View File

@ -0,0 +1,84 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.workspace-comments-test
(:require
[app.main.data.comments :as dcmt]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.edition :as dwe]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest test-handle-interrupt-draft
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:draft {:id "draft-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-open
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:open {:id "thread-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-comments-mode
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:workspace-drawing {:tool :comments}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dwe/clear-edition-mode (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-noop
(t/async
done
(let [event (dwcm/handle-interrupt)
state {}
result (ptk/watch event state (rx/empty))
emitted? (atom false)]
(->> result
(rx/subs!
(fn [_]
(reset! emitted? true))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(t/is (false? @emitted?) "should not emit any events")
(done)))))))

View File

@ -9,9 +9,11 @@
[app.common.geom.rect :as grc]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.main.data.workspace.texts :as dwt]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]))
@ -374,3 +376,34 @@
"exactly one typography was added")
(t/is (= "0.1" (:letter-spacing (first typographies)))
"float letter-spacing is normalised to 2-decimal string")))))))
;; ---------------------------------------------------------------------------
;; Tests: fix-position with degenerate selrect
;; ---------------------------------------------------------------------------
(t/deftest fix-position-zero-width-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-height-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw
(t/testing "fix-position on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))

View File

@ -12,6 +12,7 @@
[frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
@ -85,6 +86,7 @@
'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

View File

@ -52,6 +52,8 @@
- **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires.
- **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`).
- **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error).
- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument.
- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited.
## 1.4.2 (2026-01-21)

View File

@ -34,6 +34,24 @@ describe('Library', () => {
expect(Array.isArray(ctx.penpot.library.connected)).toBe(true);
});
// The Library object itself carries plugin data (it extends PluginData),
// stored on the underlying file. Exercised on the local library; the
// connected-library case (writing to a shared library's file/assets) shares
// the same code path but can't be fixtured here (connecting a library hangs).
test('local library stores plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setPluginData('k', 'v');
expect(lib.getPluginData('k')).toBe('v');
expect(lib.getPluginDataKeys()).toContain('k');
});
test('local library stores shared plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setSharedPluginData('ns', 'k', 'v');
expect(lib.getSharedPluginData('ns', 'k')).toBe('v');
expect(lib.getSharedPluginDataKeys('ns')).toContain('k');
});
test('library elements expose a libraryId', (ctx) => {
const color = ctx.penpot.library.local.createColor();
expect(typeof color.libraryId).toBe('string');
@ -136,6 +154,13 @@ describe('Library', () => {
expect(color.getPluginData('k')).toBe('v');
expect(color.getPluginDataKeys()).toContain('k');
});
test('color shared plugin data round-trips', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.setSharedPluginData('ns', 'k', 'v');
expect(color.getSharedPluginData('ns', 'k')).toBe('v');
expect(color.getSharedPluginDataKeys('ns')).toContain('k');
});
});
describe('Typographies', () => {
@ -170,6 +195,13 @@ describe('Library', () => {
expect(typo.getPluginDataKeys()).toContain('k');
});
test('typography shared plugin data round-trips', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.setSharedPluginData('ns', 'k', 'v');
expect(typo.getSharedPluginData('ns', 'k')).toBe('v');
expect(typo.getSharedPluginDataKeys('ns')).toContain('k');
});
test('typography fontFamily and fontId round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string');
@ -256,6 +288,15 @@ describe('Library', () => {
expect(comp.getPluginDataKeys()).toContain('k');
});
test('component shared plugin data round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.setSharedPluginData('ns', 'k', 'v');
expect(comp.getSharedPluginData('ns', 'k')).toBe('v');
expect(comp.getSharedPluginDataKeys('ns')).toContain('k');
});
test('component instance and mainInstance return shapes', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);

View File

@ -26,6 +26,41 @@ describe('Plugin data', () => {
if (file) {
file.setPluginData('fileKey', 'fileValue');
expect(file.getPluginData('fileKey')).toBe('fileValue');
expect(file.getPluginDataKeys()).toContain('fileKey');
}
});
test('shared plugin data round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue');
expect(file.getSharedPluginData('ns', 'fileShared')).toBe(
'fileSharedValue',
);
expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared');
}
});
test('plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setPluginData('pageKey', 'pageValue');
expect(page.getPluginData('pageKey')).toBe('pageValue');
expect(page.getPluginDataKeys()).toContain('pageKey');
}
});
test('shared plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue');
expect(page.getSharedPluginData('ns', 'pageShared')).toBe(
'pageSharedValue',
);
expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared');
}
});
@ -45,6 +80,47 @@ describe('Plugin data', () => {
expect(rect.getPluginDataKeys()).toContain('');
});
// Keys are opaque strings: no case normalization is applied. A camelCase key
// and its kebab-case spelling are distinct entries that each round-trip
// independently. Pins that behaviour so a future key-normalization regression
// (reported as camelCase keys "behaving incorrectly") would be caught here.
test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('myKey', 'camel');
rect.setPluginData('my-key', 'kebab');
expect(rect.getPluginData('myKey')).toBe('camel');
expect(rect.getPluginData('my-key')).toBe('kebab');
const keys = rect.getPluginDataKeys();
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
// Same guarantee at the file-data storage location (a distinct code path from
// shape/page objects), covering a camelCase key on the file itself.
test('a camelCase key round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setPluginData('camelCaseFileKey', 'value');
expect(file.getPluginData('camelCaseFileKey')).toBe('value');
expect(file.getPluginDataKeys()).toContain('camelCaseFileKey');
}
});
// camelCase is likewise preserved in the shared namespace and the shared key.
test('camelCase shared namespace and key round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setSharedPluginData('myNamespace', 'myKey', 'camel');
rect.setSharedPluginData('myNamespace', 'my-key', 'kebab');
expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel');
expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab');
const keys = rect.getSharedPluginDataKeys('myNamespace');
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
test('setPluginData with a non-string value throws', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);

74
tools/detect-target-branch Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# detect-target-branch
#
# Determines the target branch for a PR of the current branch.
#
# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of
# HEAD, falling back to a graph walk if name-rev returns the current branch.
#
# Pure local — no remote, no SSH, no network. Works even when the current
# branch shares its tip commit with the target (not yet diverged).
#
# Usage:
# tools/detect-target-branch # print branch name
# tools/detect-target-branch --verbose # print "branch~N"
#
# Exit status:
# 0 — target found
# 1 — no target found (orphan commit, no other branches)
verbose=false
for arg do
case "$arg" in
--verbose|-v) verbose=true ;;
--name-only) ;;
--help|-h)
sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//'
exit 0
;;
*)
echo "Unknown option: $arg" >&2
echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2
exit 1
;;
esac
done
current=$(git rev-parse --abbrev-ref HEAD)
# Use name-rev to find the nearest ref — it has smart tie-breaking
raw=$(git name-rev --name-only HEAD 2>/dev/null)
target=${raw%%~[0-9]*} # strip ~N distance suffix
# If name-rev returned the current branch itself, walk backwards
if [ "$target" = "$current" ] || [ -z "$target" ]; then
target=$(git rev-list HEAD | while read commit; do
branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \
| grep -v "^$current$" \
| head -1 || true)
if [ -n "$branch" ]; then
echo "$branch"
break
fi
done || true)
if [ -z "$target" ]; then
echo "error: unable to determine target branch" >&2
exit 1
fi
fi
if [ "$verbose" = true ]; then
target_commit=$(git rev-parse "$target" 2>/dev/null || true)
if [ -n "$target_commit" ]; then
distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0")
echo "${target}~${distance}"
else
echo "${target}~?"
fi
else
echo "$target"
fi

View File

@ -128,18 +128,24 @@ function formatEvalMessages(messages) {
function parseArgs(argv) {
const args = {
port: 6064,
port: null,
host: "127.0.0.1",
timeout: DEFAULT_TIMEOUT,
help: false,
resetSession: false,
lastError: false,
backend: false,
frontend: false,
code: null,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "-p" || a === "--port") {
if (a === "--backend") {
args.backend = true;
} else if (a === "--frontend") {
args.frontend = true;
} else if (a === "-p" || a === "--port") {
const val = argv[++i];
if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); }
args.port = parseInt(val, 10);
@ -166,6 +172,19 @@ function parseArgs(argv) {
}
}
if (args.backend && args.frontend) {
console.error("Error: --backend and --frontend are mutually exclusive.");
process.exit(1);
}
if (args.backend) {
args.port = 6064;
} else if (args.frontend) {
args.port = 3447;
} else if (args.port === null) {
args.port = 6064;
}
return args;
}
@ -177,7 +196,9 @@ Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns)
persists across invocations via a stored session ID.
Options:
-p, --port PORT nREPL port (default: 6064)
--backend Connect to backend nREPL on port 6064 (default)
--frontend Connect to frontend nREPL on port 3447
-p, --port PORT nREPL port (default: 6064; overridden by --backend/--frontend)
-H, --host HOST nREPL host (default: 127.0.0.1)
-t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000)
--reset-session Discard stored session and start fresh
@ -187,6 +208,8 @@ Options:
Examples:
${bin} '(def x 42)'
${bin} 'x'
${bin} --backend '(+ 1 2 3)'
${bin} --frontend '(js/alert "hi")'
${bin} --reset-session '(def x 0)'
${bin} --last-error
${bin} <<'EOF'