Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-05-18 15:24:02 +02:00
commit 6f41a2b729
31 changed files with 5591 additions and 175 deletions

View File

@ -86,13 +86,32 @@ batches of 50 via GraphQL to stay within API limits.
### 5. Categorize entries
Check the labels on each issue to determine which section it belongs to:
Use the **Issue Type** field (GitHub's native issue type, accessible via GraphQL
`issueType { name }`) to determine which section an entry belongs to.
**Do not** use labels or title emoji prefixes as the source of truth — they are
often inaccurate or missing.
| Label / Title prefix | Changelog section |
|----------------------|-------------------|
| `bug` label or `:bug:` title prefix | `### :bug: Bugs fixed` |
| `enhancement` label or `:sparkles:` prefix | `### :sparkles: New features & Enhancements` |
| No label | Infer from title convention, default to bug fix |
| Issue Type (`issueType.name`) | Changelog section |
|------------------------------|-------------------|
| `Bug` | `### :bug: Bugs fixed` |
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
| No type set | Fetch the issue and check its labels as a fallback: `bug` label → bugs section, otherwise default to enhancements |
To fetch Issue Types for all issues in a milestone efficiently, use a single
GraphQL query with aliases rather than N+1 REST calls:
```graphql
query {
repository(owner: "penpot", name: "penpot") {
i123: issue(number: 123) {
number state milestone { number } issueType { name }
}
i456: issue(number: 456) {
number state milestone { number } issueType { name }
}
}
}
```
**Community contribution attribution:** If the issue or its fix PR has the
`community contribution` label, add an attribution `(by @<github_username>)`
@ -205,6 +224,7 @@ Read the top of `CHANGES.md` and confirm:
can find the code changes.
- **Latest version first.** New sections are inserted at the top of the
changelog, below the `# CHANGELOG` header.
- **Issue Type determines section.** Use GitHub's `issueType` field (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`) to categorize entries. Ignore labels and title emoji prefixes — they are unreliable for categorization.
- **User-facing descriptions.** Write from the user's perspective — describe
what broke and what was fixed, not internal implementation details.
- **Community attribution.** When the issue or fix PR has the

View File

@ -32,6 +32,8 @@
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
### :sparkles: New features & Enhancements
@ -49,11 +51,8 @@
- Rename token group [#9637](https://github.com/penpot/penpot/issues/9637) (PR: [#8275](https://github.com/penpot/penpot/pull/8275))
- Duplicate token group [#9638](https://github.com/penpot/penpot/issues/9638) (PR: [#8886](https://github.com/penpot/penpot/pull/8886))
- Copy token name from contextual menu [#9639](https://github.com/penpot/penpot/issues/9639) (PR: [#8566](https://github.com/penpot/penpot/pull/8566))
- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672))
- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [#2466](https://github.com/penpot/penpot/issues/2466) (PR: [#8536](https://github.com/penpot/penpot/pull/8536))
- Add CSS linter [#9636](https://github.com/penpot/penpot/issues/9636) (PR: [#8592](https://github.com/penpot/penpot/pull/8592))
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873))
- Add per-group add button for typographies (by @eureka0928) [#5275](https://github.com/penpot/penpot/issues/5275) (PR: [#8895](https://github.com/penpot/penpot/pull/8895))
- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899))
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [#8773](https://github.com/penpot/penpot/issues/8773) (PR: [#8874](https://github.com/penpot/penpot/pull/8874))
@ -62,8 +61,6 @@
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [#2572](https://github.com/penpot/penpot/issues/2572) (PR: [#8952](https://github.com/penpot/penpot/pull/8952))
- Add Paste to replace (Cmd+Shift+V) for selected shapes (by @eureka0928) [#4240](https://github.com/penpot/penpot/issues/4240) (PR: [#9033](https://github.com/penpot/penpot/pull/9033))
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [#7794](https://github.com/penpot/penpot/issues/7794) (PR: [#8923](https://github.com/penpot/penpot/pull/8923))
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [#2910](https://github.com/penpot/penpot/issues/2910)
- Add customizable colors for ruler guides (by @Dexterity104) [#5199](https://github.com/penpot/penpot/issues/5199) (PR: [#8986](https://github.com/penpot/penpot/pull/8986))
- Persist asset search and section filter across sidebar tabs (by @eureka0928) [#2913](https://github.com/penpot/penpot/issues/2913) (PR: [#8985](https://github.com/penpot/penpot/pull/8985))
@ -74,7 +71,6 @@
- Allow customising the OIDC login button label (by @wdeveloper16) [#7027](https://github.com/penpot/penpot/issues/7027) (PR: [#9026](https://github.com/penpot/penpot/pull/9026))
- Add page separators in Workspace [#9180](https://github.com/penpot/penpot/issues/9180) (PR: [#8561](https://github.com/penpot/penpot/pull/8561))
- Preserve vector content when pasting SVG from external tools (by @RenzoMXD) [#546](https://github.com/penpot/penpot/issues/546) (PR: [#9182](https://github.com/penpot/penpot/pull/9182))
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
- Add pixel grid color picker in viewport settings (by @jack-stormentswe) [#7750](https://github.com/penpot/penpot/issues/7750) (PR: [#9155](https://github.com/penpot/penpot/pull/9155))
- Add HEX/HSB/HSL support to color picker with persistent model switcher (by @edwin-rivera-dev) [#9133](https://github.com/penpot/penpot/issues/9133) (PR: [#9134](https://github.com/penpot/penpot/pull/9134))
- Show specific invitation-link error messages (by @niwinz) [#9220](https://github.com/penpot/penpot/issues/9220) (PR: [#9223](https://github.com/penpot/penpot/pull/9223))
@ -83,15 +79,21 @@
- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [#6980](https://github.com/penpot/penpot/issues/6980) (PR: [#9053](https://github.com/penpot/penpot/pull/9053))
- Update auth hero illustration on login screen [#9532](https://github.com/penpot/penpot/issues/9532) (PR: [#9552](https://github.com/penpot/penpot/pull/9552))
- Update Open Graph link preview metadata [#9555](https://github.com/penpot/penpot/issues/9555) (PR: [#9557](https://github.com/penpot/penpot/pull/9557))
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
- Add new numeric inputs for token management on the right sidebar [#9358](https://github.com/penpot/penpot/issues/9358)
- Restore deleted team files in bulk instead of per file (by @Dexterity104) [#9246](https://github.com/penpot/penpot/issues/9246) (PR: [#9248](https://github.com/penpot/penpot/pull/9248))
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
- Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204))
### :bug: Bugs fixed
### :bug: Bugs fixed
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672))
- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873))
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361))
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
- Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204))
- Add token name on broken token pill on sidebar [#9534](https://github.com/penpot/penpot/issues/9534) (PR: [#8527](https://github.com/penpot/penpot/pull/8527))
- Fix tooltip activated when tab change [#9539](https://github.com/penpot/penpot/issues/9539) (PR: [#8719](https://github.com/penpot/penpot/pull/8719))
- Fix title on shared button [#9541](https://github.com/penpot/penpot/issues/9541) (PR: [#8696](https://github.com/penpot/penpot/pull/8696))
@ -127,7 +129,6 @@
- Fix app crash on multiselection with hidden shapes and opacity mixed value [#9666](https://github.com/penpot/penpot/issues/9666) (PR: [#8932](https://github.com/penpot/penpot/pull/8932))
- Fix gap input throwing an error [#9667](https://github.com/penpot/penpot/issues/9667) (PR: [#8984](https://github.com/penpot/penpot/pull/8984))
- Fix copy to be more specific [#9668](https://github.com/penpot/penpot/issues/9668) (PR: [#9028](https://github.com/penpot/penpot/pull/9028))
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [#8516](https://github.com/penpot/penpot/issues/8516) (PR: [#9094](https://github.com/penpot/penpot/pull/9094))
- Fix typography style creation with tokenized line-height (by @juan-flores077) [#8479](https://github.com/penpot/penpot/issues/8479) (PR: [#9121](https://github.com/penpot/penpot/pull/9121))
- Fix colorpicker layout hiding eyedropper button [#9669](https://github.com/penpot/penpot/issues/9669) (PR: [#9125](https://github.com/penpot/penpot/pull/9125))
@ -149,6 +150,10 @@
- Fix several color picker issues [#9556](https://github.com/penpot/penpot/issues/9556) (PR: [#9558](https://github.com/penpot/penpot/pull/9558))
- Fix asset icon broken on Asset tab [#9587](https://github.com/penpot/penpot/issues/9587) (PR: [#9612](https://github.com/penpot/penpot/pull/9612))
- Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549))
- Fix editing a legacy text element silently detaches its color token [#9255](https://github.com/penpot/penpot/issues/9255)
- Fix token application to grid paddings [#9494](https://github.com/penpot/penpot/issues/9494)
- Fix file crashing when switching a variant [#9259](https://github.com/penpot/penpot/issues/9259)
## 2.15.4 (Unreleased)

View File

@ -12,6 +12,7 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.db :as db]
[app.http.access-token :as actoken]
[app.http.session :as session]
[app.storage :as sto]
[integrant.core :as ig]
@ -79,9 +80,17 @@
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(defn- authenticated?
"Check if the request has an authenticated profile, either via session
or access token."
[request]
(or (some? (::session/profile-id request))
(some? (::actoken/profile-id request))))
(defn objects-handler
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires an authenticated session."
For non-public buckets (e.g. profile), requires authentication
via session cookie or access token."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
@ -90,7 +99,7 @@
{::yres/status 404}
(and (requires-auth? obj)
(nil? (::session/profile-id request)))
(not (authenticated? request)))
{::yres/status 401}
:else
@ -128,7 +137,8 @@
(defmethod ig/init-key ::routes
[_ cfg]
["/assets" {:middleware [[session/authz cfg]]}
["/assets" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/by-id/:id" {:handler (partial objects-handler cfg)}]
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])

View File

@ -302,7 +302,9 @@
::http.assets/cache-max-age (ct/duration {:hours 24})
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
::sto/storage (ig/ref ::sto/storage)
::session/manager (ig/ref ::session/manager)}
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool)}
::rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)

View File

@ -96,6 +96,7 @@
context (assoc @context :param-style pstyle)]
{::yres/status 200
::yres/headers {"content-type" "text/html; charset=utf-8"}
::yres/body (-> (io/resource template)
(tmpl/render context))})))
(fn [_]

View File

@ -0,0 +1,461 @@
;; 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
(ns backend-tests.http-assets-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
[app.http.assets :as assets]
[app.http.session :as session]
[app.rpc.commands.access-token :as access-token]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[yetti.response :as-alias yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each (th/serial
th/database-reset
th/clean-storage))
;; ----------------------------------------------------------------
;; Helpers
;; ----------------------------------------------------------------
(defn- configure-storage-backend
"Given storage map, returns a storage configured with the
appropriate backend for assets."
[storage]
(assoc storage ::sto/backend :fs))
(defn- create-storage-object!
"Create a storage object with the given bucket and content."
[storage bucket content]
(sto/put-object! storage {::sto/content (sto/content content)
:bucket bucket
:content-type "text/plain"}))
(defn- make-handler-cfg
"Build a minimal cfg map for the assets handlers."
[storage]
{::sto/storage storage
::assets/path "/assets"})
;; ----------------------------------------------------------------
;; Tests: get-id
;; ----------------------------------------------------------------
(t/deftest get-id-with-valid-uuid
(let [id (uuid/next)
request {:path-params {:id (str id)}}
result (assets/get-id request)]
(t/is (= id result))))
(t/deftest get-id-with-invalid-uuid
(let [request {:path-params {:id "not-a-uuid"}}]
(try
(assets/get-id request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
(t/deftest get-id-with-missing-id
(let [request {:path-params {}}]
(try
(assets/get-id request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — non-existent objects
;; ----------------------------------------------------------------
(t/deftest objects-handler-non-existent-object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {:path-params {:id (str (uuid/next))}}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-invalid-uuid
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {:path-params {:id "not-a-uuid"}}]
(try
(assets/objects-handler cfg request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — public buckets (no auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-public-bucket-no-auth
;; Objects in public buckets should be accessible without authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)]
(doseq [bucket ["file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "public data")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should not require auth"))
(t/is (not= 404 (::yres/status response))
(str "bucket " bucket " object should exist")))))))
(t/deftest objects-handler-public-bucket-with-auth
;; Objects in public buckets should also be accessible WITH authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "file-media-object" "public data")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — non-public buckets (auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-non-public-bucket-no-auth
;; Objects in non-public buckets should return 401 without authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-session-auth
;; Objects in non-public buckets should be accessible with session auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-access-token-auth
;; Objects in non-public buckets should be accessible with access token auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-both-auth
;; Objects should be accessible when both session and access token auth are present.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)
::actoken/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — all non-public buckets
;; ----------------------------------------------------------------
(t/deftest objects-handler-all-non-public-buckets-require-auth
;; Verify that all buckets NOT in the public set require authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)]
(doseq [bucket ["profile"
"tempfile"
"file-data"
"file-thumbnail"
"file-change"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "some data")
request {:path-params {:id (str (:id object))}}]
;; Without auth → 401
(let [response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response))
(str "bucket " bucket " should require auth")))
;; With session auth → not 401
(let [response (assets/objects-handler cfg (assoc request ::session/profile-id (:id profile)))]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should be accessible with session auth")))
;; With access token auth → not 401
(let [response (assets/objects-handler cfg (assoc request ::actoken/profile-id (:id profile)))]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should be accessible with access token auth"))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — serve-object response (FS backend)
;; ----------------------------------------------------------------
(t/deftest objects-handler-fs-backend-serves-object
;; Verify that the FS backend returns the correct response structure.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
;; FS backend returns 204 with x-accel-redirect header
(t/is (= 204 (::yres/status response)))
(t/is (some? (get (::yres/headers response) "x-accel-redirect")))
(t/is (= "text/plain" (get (::yres/headers response) "content-type")))
(t/is (some? (get (::yres/headers response) "cache-control")))))
(t/deftest objects-handler-fs-backend-accel-redirect-path
;; Verify that x-accel-redirect contains the object's relative path.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)
redirect (get (::yres/headers response) "x-accel-redirect")]
;; The redirect path should contain the object's relative path
(t/is (string? redirect))
(t/is (clojure.string/includes? redirect (sto/object->relative-path object)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — cache headers
;; ----------------------------------------------------------------
(t/deftest objects-handler-cache-control-header
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-media-object" "content")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)
cc (get (::yres/headers response) "cache-control")]
(t/is (string? cc))
(t/is (clojure.string/starts-with? cc "max-age="))))
;; ----------------------------------------------------------------
;; Tests: middleware integration — session auth end-to-end
;; ----------------------------------------------------------------
(t/deftest session-auth-integration
;; Test the full session auth flow: create session → assign token →
;; authenticate request → access protected asset.
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
;; Create a session and generate a token
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "test-agent"})
(#'session/assign-token cfg))
;; Create a storage object in a non-public bucket
storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
object (create-storage-object! storage "profile" "profile data")
;; Simulate what the middleware chain does:
;; 1. mw/auth extracts token from cookie and sets ::http/auth-data
;; 2. session/authz reads ::http/auth-data and sets ::session/profile-id
request {::http/auth-data {:type :cookie
:token (:token session)
:claims {:sid (:id session)
:uid (:id profile)}
:metadata {:ver 1}}
:path-params {:id (str (:id object))}}
;; Apply session/authz middleware
handler (#'session/wrap-authz
(fn [req]
;; This is where the actual handler would be called
;; We verify that ::session/profile-id is set
req)
{::session/manager manager})
result (handler request)]
;; Verify the session auth set the profile-id
(t/is (= (:id profile) (::session/profile-id result)))
(t/is (some? (::session/session result)))))
;; ----------------------------------------------------------------
;; Tests: middleware integration — access token auth end-to-end
;; ----------------------------------------------------------------
(t/deftest access-token-auth-integration
;; Test the full access token flow: create token → authenticate
;; request → access protected asset.
(let [profile (th/create-profile* 1)
;; Create an access token in the database
atoken (db/tx-run! th/*system*
access-token/create-access-token
(:id profile) "test-token" nil nil)
;; Create a storage object in a non-public bucket
storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
object (create-storage-object! storage "profile" "profile data")
;; Simulate what the middleware chain does:
;; 1. mw/auth extracts token from Authorization header and sets ::http/auth-data
;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id
request {::http/auth-data {:type :token
:token (:token atoken)
:claims {:tid (:id atoken)}}
:path-params {:id (str (:id object))}}
;; Apply actoken/authz middleware
handler (#'actoken/wrap-authz
(fn [req]
;; Verify that ::actoken/profile-id is set
req)
th/*system*)
result (handler request)]
;; Verify the access token auth set the profile-id
(t/is (= (:id profile) (::actoken/profile-id result)))))
;; ----------------------------------------------------------------
;; Tests: middleware chain — combined session + access token
;; ----------------------------------------------------------------
(t/deftest combined-middleware-chain
;; Test that both session/authz and actoken/authz work together
;; in the middleware chain, matching the assets route configuration.
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
;; Create a session
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "test-agent"})
(#'session/assign-token cfg))
;; Create an access token
atoken (db/tx-run! th/*system*
access-token/create-access-token
(:id profile) "test-token" nil nil)
;; Build the middleware chain like assets routes do:
;; session/authz → actoken/authz → handler
inner-handler (fn [request] request)
with-actoken (#'actoken/wrap-authz inner-handler th/*system*)
with-session (#'session/wrap-authz with-actoken {::session/manager manager})]
(t/testing "session cookie auth sets ::session/profile-id"
(let [request {::http/auth-data {:type :cookie
:token (:token session)
:claims {:sid (:id session)
:uid (:id profile)}
:metadata {:ver 1}}}
result (with-session request)]
(t/is (= (:id profile) (::session/profile-id result)))))
(t/testing "access token auth sets ::actoken/profile-id"
(let [request {::http/auth-data {:type :token
:token (:token atoken)
:claims {:tid (:id atoken)}}}
result (with-session request)]
(t/is (= (:id profile) (::actoken/profile-id result)))))
(t/testing "no auth sets neither profile-id"
(let [request {}
result (with-session request)]
(t/is (nil? (::session/profile-id result)))
(t/is (nil? (::actoken/profile-id result)))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — edge cases
;; ----------------------------------------------------------------
(t/deftest objects-handler-nil-profile-id-in-session
;; When session auth is present but profile-id is nil (e.g. invalid session),
;; non-public objects should still be denied.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "data")
request {:path-params {:id (str (:id object))}
::session/profile-id nil}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-nil-profile-id-in-access-token
;; When access token auth is present but profile-id is nil,
;; non-public objects should still be denied.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "data")
request {:path-params {:id (str (:id object))}
::actoken/profile-id nil}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-empty-request
;; A request with no path-params should raise a not-found error.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {}]
(try
(assets/objects-handler cfg request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — expired objects
;; ----------------------------------------------------------------
(t/deftest objects-handler-expired-object
;; Expired objects should return 404 (get-object filters them out).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (sto/put-object! storage {::sto/content (sto/content "expired")
::sto/expired-at (ct/now)
:bucket "profile"
:content-type "text/plain"})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))

View File

@ -12,10 +12,12 @@
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.schema.test :as smt]
[app.config :as cf]
[app.rpc :as-alias rpc]
[app.rpc.doc :as rpc.doc]
[backend-tests.helpers :as th]
[clojure.test :as t]))
[clojure.test :as t]
[yetti.response :as-alias yres]))
(t/use-fixtures :once th/state-init)
@ -31,6 +33,17 @@
false)))
{:num 15}))
(t/deftest doc-handler-returns-html-content-type
(with-redefs [cf/flags #{:backend-api-doc}]
(let [methods (::rpc/methods th/*system*)
handler (#'rpc.doc/handler :methods methods
:label "main"
:entrypoint "http://localhost/api/main/methods"
:openapi "http://localhost/api/main/doc/openapi"
:template "app/templates/main-api-doc.tmpl")
request {}
response (handler request)]
(t/is (= 200 (::yres/status response)))
(t/is (= "text/html; charset=utf-8"
(get-in response [::yres/headers "content-type"]))))))

View File

@ -177,7 +177,9 @@
(defn not-empty?
[coll]
(boolean (seq coll)))
(if (coll? coll)
(boolean (seq coll))
(not (nil? coll))))
(defn editable-collection?
[m]

View File

@ -46,8 +46,8 @@
(with-meta changes
{::page-id page-id})))
([]
{:redo-changes []
:undo-changes '()})
{:redo-changes [] ;; redo-changes is a vector so that conj adds things at the end, in order of execution
:undo-changes '()}) ;; undo-changes is a list to conj things at the beginning, so they execute in the reverse order when undoing several changes
([origin]
{:redo-changes []
:undo-changes '()

View File

@ -2129,8 +2129,8 @@
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
(loop [attrs updatable-attrs
roperations [{:type :set-touched :touched (:touched previous-shape)}]
uoperations (list {:type :set-touched :touched (:touched current-shape)})]
roperations []
uoperations '()]
(if-let [attr (first attrs)]
(let [sync-group
(ctk/resolve-sync-group (:type previous-shape) attr)
@ -2272,7 +2272,13 @@
(let [updated-attrs (into #{} (comp (filter #(= :set (:type %)))
(map :attr))
roperations)]
roperations)
updated-sync-groups (into #{}
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
updated-attrs)
new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
roperations (into [{:type :set-touched :touched new-touched}] roperations)
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
(cond-> changes
(> (count roperations) 1)
(-> (add-update-attr-changes current-shape container roperations uoperations)

View File

@ -242,8 +242,11 @@
acc)
:else
;; If the key is not :text, and they are different, it is an attribute differece
(if (not= v1 v2)
;; If the key is not :text, and they are different, it is an attribute difference.
;; Take into account that some processes remove empty attributes, so in some
;; cases we will compare [] with nil, and this is not a difference.
(if (and (not= v1 v2)
(or (d/not-empty? v1) (d/not-empty? v2)))
(attribute-cb acc k)
acc))))
#{}

View File

@ -17,6 +17,7 @@
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.types.component :as ctk]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@ -38,13 +39,13 @@
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
copy01' (ths/get-shape file' :copy01)]
(thf/dump-file file :keys [:width])
;; The copy had width 5 before the switch
(t/is (= (:width copy01) 5))
;; The rect has width 15 after the switch
;; The copy has width 15 after the switch
(t/is (= (:width copy01') 15))))
(t/deftest test-simple-switch
@ -64,15 +65,15 @@
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
copy01' (ths/get-shape file' :copy01)
rect01' (get-in page' [:objects (-> copy01' :shapes first)])]
;; The rect had width 5 before the switch
(t/is (= (:width rect01) 5))
;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15))))
(t/is (= (:width rect01') 15))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (identical variants)
@ -103,9 +104,9 @@
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
copy01' (ths/get-shape file' :copy01)]
(thf/dump-file file :keys [:width])
;; The copy had width 25 before the switch
(t/is (= (:width copy01) 25))
@ -140,16 +141,16 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
copy01' (ths/get-shape file' :copy01)
rect01' (get-in page' [:objects (-> copy01' :shapes first)])]
;; The rect had width 25 before the switch
(t/is (= (:width rect01) 25))
;; The override is keept: The rect still has width 25 after the switch
(t/is (= (:width rect02') 25))))
(t/is (= (:width rect01') 25))))
;; ============================================================
;; SIMPLE ATTRIBUTE OVERRIDES (different variants)
@ -183,17 +184,193 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
copy01' (ths/get-shape file' :copy01)
rect01' (get-in page' [:objects (-> copy01' :shapes first)])]
;; The rect had width 25 before the switch
(t/is (= (:width rect01) 25))
;; The override isn't keept, because the property is different in the mains
;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15))))
(t/is (= (:width rect01') 15))))
;; ============================================================
;; NESTED COPY SWITCH (no overrides)
;; ============================================================
(t/deftest test-nested-switch-in-main
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 15}})
(tho/add-frame :m03)
(thc/instantiate-component :c01
:copy01
:parent-label :m03)
(thc/make-component :c03 :m03))
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
copy01' (ths/get-shape file' :copy01)]
(thf/dump-file file :keys [:width])
;; The copy had width 5 before the switch
(t/is (= (:width copy01) 5))
;; The copy has width 15 after the switch
(t/is (= (:width copy01') 15))
;; The copy is not touched but has swap slot
(t/is (= (count (:touched copy01')) 1))
(t/is (= (ctk/get-swap-slot copy01') (thi/id :copy01)))))
(t/deftest test-nested-switch-in-copy
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 15}})
(tho/add-frame :m03)
(thc/instantiate-component :c01
:nested01
:parent-label :m03)
(thc/make-component :c03 :m03)
(thc/instantiate-component :c03
:nested02
:children-labels [:child01]))
child01 (ths/get-shape file :child01)
;; ==== Action
file' (tho/swap-component-in-shape file :child01 :c02 {:keep-touched? true})
child01' (ths/get-shape file' :child01)]
(thf/dump-file file :keys [:width])
;; The copy had width 5 before the switch
(t/is (= (:width child01) 5))
;; The copy has width 15 after the switch
(t/is (= (:width child01') 15))
;; The copy is not touched but has swap slot
(t/is (= (count (:touched child01')) 1))
(t/is (= (ctk/get-swap-slot child01') (thi/id :nested01)))))
;; ============================================================
;; NESTED COPY SWITCH (with overrides)
;; ============================================================
(t/deftest test-nested-switch-in-main-with-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 15}})
(tho/add-frame :m03)
(thc/instantiate-component :c01
:copy01
:parent-label :m03)
(thc/make-component :c03 :m03))
page (thf/current-page file)
fills (ths/sample-fills-color :fill-color "#fabada")
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :copy01)}
(fn [shape]
(assoc shape
:width 25
:fills fills))
(:objects page)
{})
file (thf/apply-changes file changes)
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true})
copy01' (ths/get-shape file' :copy01)]
(thf/dump-file file :keys [:width :touched])
;; The copy had fill color before the switch
(t/is (= (:fills copy01) fills))
;; The copy still has fill color after the switch
(t/is (= (:fills copy01') fills))
;; The copy had width 25 before the switch
(t/is (= (:width copy01) 25))
;; The copy gets the switched variant width 15, because this is the value changed in the variant
(t/is (= (:width copy01') 15))
;; The copy is fills touched and has swap slot
(t/is (= (count (:touched copy01')) 2))
(t/is (= (ctk/get-swap-slot copy01') (thi/id :copy01)))
(t/is (contains? (:touched copy01') :fill-group))))
(t/deftest test-nested-switch-in-copy-with-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 15}})
(tho/add-frame :m03)
(thc/instantiate-component :c01
:nested01
:parent-label :m03)
(thc/make-component :c03 :m03)
(thc/instantiate-component :c03
:copy02
:children-labels [:nested02]))
page (thf/current-page file)
fills (ths/sample-fills-color :fill-color "#fabada")
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :nested02)}
(fn [shape]
(assoc shape
:width 25
:fills fills))
(:objects page)
{})
file (thf/apply-changes file changes)
nested02 (ths/get-shape file :nested02)
;; ==== Action
file' (tho/swap-component-in-shape file :nested02 :c02 {:keep-touched? true})
nested02' (ths/get-shape file' :nested02)]
(thf/dump-file file :keys [:width])
;; The copy had fill color before the switch
(t/is (= (:fills nested02) fills))
;; The copy still has fill color after the switch
(t/is (= (:fills nested02') fills))
;; The copy had width 5 before the switch
(t/is (not= (:width nested02) 5))
;; The copy gets the switched variant width 15, because this is the value changed in the variant
(t/is (= (:width nested02') 15))
;; The copy is fills touched and has swap slot
(t/is (= (count (:touched nested02')) 2))
(t/is (= (ctk/get-swap-slot nested02') (thi/id :nested01)))
(t/is (contains? (:touched nested02') :fill-group))))
;; ============================================================
;; TEXT OVERRIDES (identical variants)

View File

@ -11,7 +11,7 @@
},
"type": "module",
"dependencies": {
"archiver": "^8.0.0",
"archiver": "7.0.1",
"cookies": "^0.9.1",
"date-fns": "^4.1.0",
"generic-pool": "^3.9.0",

365
exporter/pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
dependencies:
archiver:
specifier: ^8.0.0
version: 8.0.0
specifier: 7.0.1
version: 7.0.1
cookies:
specifier: ^0.9.1
version: 0.9.1
@ -61,6 +61,14 @@ packages:
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@ -69,9 +77,29 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
archiver@8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@ -84,9 +112,8 @@ packages:
react-native-b4a:
optional: true
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
@ -135,9 +162,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
@ -157,9 +183,16 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
compress-commons@7.0.1:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
engines: {node: '>=18'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
cookies@0.9.1:
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
@ -176,9 +209,13 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@7.0.1:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
engines: {node: '>=18'}
crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@ -232,6 +269,15 @@ packages:
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -250,6 +296,10 @@ packages:
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -259,6 +309,14 @@ packages:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@ -281,13 +339,23 @@ packages:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
keygrip@1.1.0:
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
engines: {node: '>= 0.6'}
@ -306,15 +374,26 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -326,6 +405,17 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
@ -354,9 +444,8 @@ packages:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@3.0.0:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
readdir-glob@1.1.3:
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
@ -381,6 +470,18 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -402,12 +503,28 @@ packages:
streamx@2.25.0:
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
resolution: {tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180}
version: 4.0.0
@ -441,6 +558,19 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
@ -460,9 +590,9 @@ packages:
xregexp@5.1.2:
resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==}
zip-stream@7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
snapshots:
@ -472,23 +602,53 @@ snapshots:
'@ioredis/commands@1.5.1': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@trysound/sax@0.2.0': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
archiver@8.0.0:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
async: 3.2.6
buffer-crc32: 1.0.0
is-stream: 4.0.1
color-convert: 2.0.1
ansi-styles@6.2.3: {}
archiver-utils@5.0.2:
dependencies:
glob: 10.5.0
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.21
normalize-path: 3.0.0
readable-stream: 4.7.0
readdir-glob: 3.0.0
archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
async: 3.2.6
buffer-crc32: 1.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
tar-stream: 3.2.0
zip-stream: 7.0.5
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@ -498,7 +658,7 @@ snapshots:
b4a@1.8.1: {}
balanced-match@4.0.4: {}
balanced-match@1.0.2: {}
bare-events@2.8.2: {}
@ -536,9 +696,9 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.6:
brace-expansion@2.1.0:
dependencies:
balanced-match: 4.0.4
balanced-match: 1.0.2
buffer-crc32@1.0.0: {}
@ -553,11 +713,17 @@ snapshots:
cluster-key-slot@1.1.2: {}
compress-commons@7.0.1:
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
crc32-stream: 7.0.1
is-stream: 4.0.1
crc32-stream: 6.0.0
is-stream: 2.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
@ -572,11 +738,17 @@ snapshots:
crc-32@1.2.2: {}
crc32-stream@7.0.1:
crc32-stream@6.0.0:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@ -629,6 +801,12 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
eastasianwidth@0.2.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
entities@4.5.0: {}
event-target-shim@5.0.1: {}
@ -643,11 +821,27 @@ snapshots:
fast-fifo@1.3.2: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
generic-pool@3.9.0: {}
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.9
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
graceful-fs@4.2.11: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@ -680,10 +874,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
is-stream@4.0.1: {}
is-fullwidth-code-point@3.0.0: {}
is-stream@2.0.1: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
keygrip@1.1.0:
dependencies:
tsscmp: 1.0.6
@ -698,13 +902,21 @@ snapshots:
lodash@4.17.21: {}
lru-cache@10.4.3: {}
mdn-data@2.0.28: {}
mdn-data@2.12.2: {}
minimatch@10.2.5:
minimatch@5.1.9:
dependencies:
brace-expansion: 5.0.6
brace-expansion: 2.1.0
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.0
minipass@7.1.3: {}
ms@2.1.3: {}
@ -714,6 +926,15 @@ snapshots:
dependencies:
boolbase: 1.0.0
package-json-from-dist@1.0.1: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.3
playwright-core@1.60.0: {}
playwright@1.60.0:
@ -751,9 +972,9 @@ snapshots:
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@3.0.0:
readdir-glob@1.1.3:
dependencies:
minimatch: 10.2.5
minimatch: 5.1.9
redis-errors@1.2.0: {}
@ -771,6 +992,14 @@ snapshots:
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@ -793,6 +1022,18 @@ snapshots:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
@ -801,6 +1042,14 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180:
dependencies:
'@trysound/sax': 0.2.0
@ -843,6 +1092,22 @@ snapshots:
util-deprecate@1.0.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.2.0
ws@8.20.1: {}
xml-js@1.6.11:
@ -853,8 +1118,8 @@ snapshots:
dependencies:
'@babel/runtime-corejs3': 7.28.4
zip-stream@7.0.5:
zip-stream@6.0.1:
dependencies:
compress-commons: 7.0.1
normalize-path: 3.0.0
archiver-utils: 5.0.2
compress-commons: 6.0.2
readable-stream: 4.7.0

File diff suppressed because one or more lines are too long

View File

@ -172,6 +172,7 @@ export class WorkspacePage extends BaseWebSocketPage {
this.toolbarOptions = page.getByTestId("toolbar-options");
this.rectShapeButton = page.getByRole("button", { name: "Rectangle (R)" });
this.ellipseShapeButton = page.getByRole("button", { name: "Ellipse (E)" });
this.textShapeButton = page.getByRole("button", { name: "Text (T)" });
this.moveButton = page.getByRole("button", { name: "Move (V)" });
this.boardButton = page.getByRole("button", { name: "Board (B)" });
this.toggleToolbarButton = page.getByRole("button", {

View File

@ -214,6 +214,11 @@ test("Multiselection of text and typographies", async ({ page }) => {
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
});
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
const plainTextLayer = workspacePage.layers.getByTestId("layer-row").nth(5);
const plainTextLayerTwo = workspacePage.layers
.getByTestId("layer-row")

View File

@ -315,3 +315,19 @@ test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
await expect(fontSizeInput).toHaveValue("");
await expect(fontSizeInput).toHaveAttribute("placeholder", "Mixed");
});
// This is to prevent QA tests from failing due to playwright
// considering 0-width text boxes as invisible
test("BUG 14098 - Fix text editor having 0 width or height", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.textShapeButton.click();
await workspace.clickAt(200, 200);
const textEditor = workspace.page.locator(`div[class*="viewport"]`).first().getByRole('textbox').first();
await expect(textEditor).toBeVisible();
});

View File

@ -1030,6 +1030,56 @@ test("BUG: 13930, Token colors are shown on selected colors section", async ({
).toBeVisible();
});
test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar does not change values", async ({
page,
}) => {
// Setup the workspace with token features enabled
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Transform a rectangle into a grid container to expose gap properties
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
const layoutSection = page.getByTestId("inspect-layout");
await expect(layoutSection).toBeVisible();
const addLayoutButton = layoutSection
.getByRole("button", { name: "Add layout" })
.first();
await addLayoutButton.click();
await page.getByText("Grid layout").click();
// Apply a dimension token to the vertical padding property
await layoutSection.getByLabel("Open token list").nth(2).click();
const tokenDimensionMd = layoutSection.getByRole("option", {
name: "dimension.md",
});
await expect(tokenDimensionMd).toBeVisible();
await tokenDimensionMd.click();
// Expand padding to all sides
await layoutSection.getByRole('button', { name: 'Show 4 sided padding options' }).click();
const topPaddingSection = layoutSection.getByLabel("Top padding");
const bottomPaddingSection = layoutSection.getByLabel("Bottom padding");
await expect(topPaddingSection).toBeVisible();
// Check if token is still applied to top and bottom padding
await expect(topPaddingSection.getByLabel("Detach token")).toBeVisible();
await expect(bottomPaddingSection.getByLabel("Detach token")).toBeVisible();
// Check if the value of the attribute is still correct
await expect(
await topPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
).toBe("16");
await expect(
await bottomPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
).toBe("16");
});
test.describe("Numeric Input and Token Integration Tests", () => {
test("Token pill persists after blur in gap inputs", async ({ page }) => {
// Setup the workspace with token features enabled

View File

@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
});
test("BUG 13958 - Fill token gets detached when editing text shape", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
// Load a file that has a text shape, whose content contains a `:fills []` in the root node.
// This attribute is removed when editing the text, causing the old code to detect that the
// fills had changed and detaching the token.
await workspacePage.mockGetFile("workspace/get-file-13958.json");
await workspacePage.goToWorkspace();
// Check token is attached to the shape
await workspacePage.clickLeafLayer("Design tokens are a set");
await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible();
// Enter and exit the text editor
await workspacePage.page.keyboard.press("Enter");
await expect(workspacePage.page.getByTestId("text-editor")).toBeVisible();
await workspacePage.page.keyboard.press("Escape");
await expect(workspacePage.page.getByTestId("text-editor")).not.toBeAttached();
// Assert token is still attached to the shape
await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible();
});

View File

@ -121,6 +121,11 @@ test("BUG 13385 - Fix viewport not updating when restoring version", async ({ pa
await workspacePage.mockGetFile("workspace/get-file-13385.json");
await workspacePage.mockRPC("get-profiles-for-file-comments?file-id=*", "workspace/get-profiles-for-file-comments-13385.json");
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
// navigate to workspace and check that the circle shape is not there
await workspacePage.goToWorkspace();
await expect(workspacePage.layers.getByText("Ellipse")).not.toBeVisible();
@ -141,4 +146,4 @@ test("BUG 13385 - Fix viewport not updating when restoring version", async ({ pa
// assert that the circle shape exists
await expect(workspacePage.layers.getByText("Ellipse")).toBeVisible();
});
});

View File

@ -203,6 +203,38 @@
(rx/of (ptk/data-event ::all-libraries-resolved {:file-id file-id})))
(rx/take-until stopper-s))))))
(defn check-file-position-data
[file-id]
(ptk/reify ::fix-position-data
ptk/WatchEvent
(watch [it state _]
(let [file (dsh/lookup-file state file-id)
changes
(->> file :data :pages
(mapcat
(fn [page-id]
(->> (dsh/lookup-page-objects state file-id page-id)
(vals)
(filter cfh/text-shape?)
(filter #(nil? (:position-data %)))
(map (fn [shape]
{:type :mod-obj
:id (:id shape)
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val (wasm.api/calculate-position-data shape)
:ignore-touched true
:ignore-geometry true}]})))))
(into []))]
(rx/of (dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:origin it
:tags #{:position-data}}))))))
(defn- workspace-initialized
[file-id]
(ptk/reify ::workspace-initialized

View File

@ -423,8 +423,8 @@
(obj/merge!
#js {"--editor-container-width" "auto"
"--editor-container-height" "auto"
"--editor-container-min-width" (dm/str selrect-width "px")
"--editor-container-min-height" (dm/str selrect-height "px")
"--editor-container-min-width" (dm/str (max 1 selrect-width) "px")
"--editor-container-min-height" (dm/str (max 1 selrect-height) "px")
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")
:display "flex"})

View File

@ -1377,6 +1377,7 @@
[:div {:class (stl/css :padding-row)}
[:> padding-section* {:value (:layout-padding values)
:type (:layout-padding-type values)
:ids ids
:applied-tokens applied-tokens
:on-type-change on-padding-type-change
:on-change on-padding-change}]]]

View File

@ -220,10 +220,11 @@
name]
[:div {:class (stl/css :page-actions)}
(when (and deletable? (not read-only?))
[:> icon-button* {:variant "ghost"
[:> icon-button* {:variant "action"
:aria-label (tr "modals.delete-page.title")
:on-click on-delete
:icon-size "s"
:icon-class (stl/css :page-delete-button-icon)
:icon i/delete}])]])])]])))
;; --- Page Item Wrapper

View File

@ -55,6 +55,10 @@
margin-bottom: deprecated.$s-12;
}
.page-delete-button-icon {
color: transparent;
}
.page-element {
@include deprecated.body-small-typography;
@ -102,23 +106,6 @@
height: deprecated.$s-32;
display: flex;
align-items: center;
button {
@include deprecated.button-style;
@include deprecated.flex-center;
width: deprecated.$s-24;
height: 100%;
opacity: deprecated.$op-0;
svg {
@extend %button-icon-small;
color: transparent;
fill: none;
stroke: var(--icon-foreground);
}
}
}
.element-name {

View File

@ -17,6 +17,7 @@
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
@ -455,7 +456,10 @@
;; blank canvas (first load) visible while shapes load.
;; The loading overlay is suppressed because on-shapes-ready
;; is set.
(wasm.api/initialize-viewport base-objects zoom vbox :background background)
(wasm.api/initialize-viewport base-objects zoom vbox
:background background
:on-shapes-ready
#(st/emit! (dw/check-file-position-data file-id)))
(reset! initialized? true))
(when (and (some? vern) (not= vern (mf/ref-val last-vern-ref)))

View File

@ -121,9 +121,11 @@ pub extern "C" fn render(timestamp: i32) -> Result<()> {
// modifier set, so the cost is paid once per rAF rather than
// once per pointer move.
if get_render_state().options.is_interactive_transform() {
let ids = state.shapes.modifier_ids();
// Collect into an owned Vec to release the immutable borrow on
// `state.shapes` before the mutable `rebuild_modifier_tiles` call.
let ids = state.shapes.modifier_ids().to_vec();
if !ids.is_empty() {
state.rebuild_modifier_tiles(ids)?;
state.rebuild_modifier_tiles(&ids)?;
}
}
state
@ -856,9 +858,8 @@ pub extern "C" fn set_modifiers() -> Result<()> {
with_state!(state, {
state.set_modifiers(modifiers);
// TO CHECK
if !get_render_state().options.is_interactive_transform() {
state.rebuild_modifier_tiles(ids)?;
state.rebuild_modifier_tiles(&ids)?;
}
});
Ok(())

View File

@ -3040,24 +3040,27 @@ impl RenderState {
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
// over/inside them), without doing expensive ancestor walks per node.
let moved_bounds =
if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() {
let mut acc: Option<Rect> = None;
for id in tree.modifier_ids().iter() {
let Some(s) = tree.get(id) else { continue };
let r = self.get_cached_extrect(s, tree, 1.0);
acc = Some(match acc {
None => r,
Some(mut prev) => {
prev.join(r);
prev
}
});
}
acc
} else {
None
};
//
// `modifier_ids` is pre-computed once here and reused throughout the loop to avoid
// repeated allocations (formerly O(N_shapes) HashMap builds) per node.
let modifier_ids = tree.modifier_ids();
let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() {
let mut acc: Option<Rect> = None;
for id in modifier_ids.iter() {
let Some(s) = tree.get(id) else { continue };
let r = self.get_cached_extrect(s, tree, 1.0);
acc = Some(match acc {
None => r,
Some(mut prev) => {
prev.join(r);
prev
}
});
}
acc
} else {
None
};
while let Some(node_render_state) = self.pending_nodes.pop() {
let node_id = node_render_state.id;
@ -3136,7 +3139,7 @@ impl RenderState {
let use_cached = self.should_use_cached_top_level_during_interactive(
node_id,
tree,
&tree.modifier_ids(),
modifier_ids,
moved_bounds,
);
@ -3857,7 +3860,7 @@ impl RenderState {
pub fn rebuild_modifier_tiles(
&mut self,
tree: ShapesPoolMutRef<'_>,
ids: Vec<Uuid>,
ids: &[Uuid],
) -> Result<()> {
// During interactive transform, skip ancestor invalidation: walking up to the
// parent frame evicts every tile the frame covers, including dense tiles with
@ -3865,9 +3868,9 @@ impl RenderState {
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
// the committing code path (rebuild_touched_tiles).
if self.options.is_interactive_transform() {
self.update_tiles_shapes(&ids, tree)?;
self.update_tiles_shapes(ids, tree)?;
} else {
let ancestors = all_with_ancestors(&ids, tree, false);
let ancestors = all_with_ancestors(ids, tree, false);
self.update_tiles_shapes(&ancestors, tree)?;
}
Ok(())

View File

@ -225,8 +225,7 @@ impl State {
let _ = get_render_state().render_preview(&self.shapes, timestamp);
}
pub fn rebuild_modifier_tiles(&mut self, ids: Vec<Uuid>) -> Result<()> {
// Index-based storage is safe
pub fn rebuild_modifier_tiles(&mut self, ids: &[Uuid]) -> Result<()> {
get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids)
}

View File

@ -49,6 +49,11 @@ pub struct ShapesPoolImpl {
modified_shape_cache: HashMap<usize, OnceCell<Shape>>,
/// Transform modifiers, keyed by index
modifiers: HashMap<usize, skia::Matrix>,
/// UUIDs of shapes that have an active transform modifier, kept in sync
/// with `modifiers`. Stored explicitly so that `modifier_ids()` is O(K)
/// (K = number of modified shapes) instead of O(N_shapes) — avoids
/// building a full reverse-index HashMap on every call.
modifier_uuids: Vec<Uuid>,
/// Structure entries, keyed by index
structure: HashMap<usize, Vec<StructureEntry>>,
/// Scale content values, keyed by index
@ -69,6 +74,7 @@ impl ShapesPoolImpl {
modified_shape_cache: HashMap::default(),
modifiers: HashMap::default(),
modifier_uuids: Vec::new(),
structure: HashMap::default(),
scale_content: HashMap::default(),
}
@ -238,7 +244,11 @@ impl ShapesPoolImpl {
}
self.modifiers = modifiers_with_idx;
// Compute ancestors before consuming `ids` so we can move it into
// `modifier_uuids` without a clone.
let all_ids = shapes::all_with_ancestors(&ids, self, true);
// Keep modifier_uuids in sync so modifier_ids() is O(K) not O(N_shapes).
self.modifier_uuids = ids;
for uuid in all_ids {
if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() {
self.modified_shape_cache.insert(idx, OnceCell::new());
@ -300,19 +310,9 @@ impl ShapesPoolImpl {
pub fn clean_all(&mut self) -> Vec<Uuid> {
self.clean_shape_cache();
let modified_uuids: Vec<Uuid> = if self.modifiers.is_empty() {
Vec::new()
} else {
let mut idx_to_uuid: HashMap<usize, Uuid> =
HashMap::with_capacity(self.uuid_to_idx.len());
for (uuid, idx) in self.uuid_to_idx.iter() {
idx_to_uuid.insert(*idx, *uuid);
}
self.modifiers
.keys()
.filter_map(|idx| idx_to_uuid.get(idx).copied())
.collect()
};
// `modifier_uuids` is kept in sync with `modifiers` by `set_modifiers`,
// so we can take it directly — no need to rebuild a reverse index.
let modified_uuids = std::mem::take(&mut self.modifier_uuids);
self.modifiers = HashMap::default();
self.structure = HashMap::default();
@ -325,18 +325,12 @@ impl ShapesPoolImpl {
/// Used by the throttled drag path so per-rAF tile invalidation can
/// be done once with the current modifier set instead of once per
/// pointer move.
pub fn modifier_ids(&self) -> Vec<Uuid> {
if self.modifiers.is_empty() {
return Vec::new();
}
let mut idx_to_uuid: HashMap<usize, Uuid> = HashMap::with_capacity(self.uuid_to_idx.len());
for (uuid, idx) in self.uuid_to_idx.iter() {
idx_to_uuid.insert(*idx, *uuid);
}
self.modifiers
.keys()
.filter_map(|idx| idx_to_uuid.get(idx).copied())
.collect()
///
/// Returns a reference to avoid allocation on every call — callers
/// inside hot render loops should hold this reference rather than
/// calling `modifier_ids()` repeatedly.
pub fn modifier_ids(&self) -> &[Uuid] {
&self.modifier_uuids
}
pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl {
@ -363,6 +357,7 @@ impl ShapesPoolImpl {
uuid_to_idx,
modified_shape_cache: HashMap::default(),
modifiers: HashMap::default(),
modifier_uuids: Vec::new(),
structure: HashMap::default(),
scale_content: HashMap::default(),
}
@ -409,6 +404,7 @@ impl Clone for ShapesPoolImpl {
// so it gets lazily rebuilt on demand rather than cloning OnceCell state.
modified_shape_cache: HashMap::default(),
modifiers: self.modifiers.clone(),
modifier_uuids: self.modifier_uuids.clone(),
structure: self.structure.clone(),
scale_content: self.scale_content.clone(),
}