diff --git a/.agents/skills/update-changelog/SKILL.md b/.agents/skills/update-changelog/SKILL.md index b6803bce46..c83ab8d1f7 100644 --- a/.agents/skills/update-changelog/SKILL.md +++ b/.agents/skills/update-changelog/SKILL.md @@ -6,7 +6,7 @@ description: Update the project CHANGES.md with issues from a given GitHub miles # Skill: update-changelog Entry point for all changelog work. All rules — sections, exclusions, entry -format, the `:boom:` and `:rocket:` label contracts, pre-flight, and the +format, the `:boom:`, `:rocket:` and `:lock:` contracts, pre-flight, and the anomaly report — live in `mem:workflow/updating-changelog` (file: `.serena/memories/workflow/updating-changelog.md`). This skill routes to that memory; it does not repeat it. @@ -28,18 +28,22 @@ memory; it does not repeat it. ## What to Read 1. `mem:workflow/updating-changelog` — the canonical flow: sources of truth, - layout, entry rules, the `## Breaking change note` comment contract, - pre-flight checks, anomaly types, and pitfalls. -2. `mem:scripts/changelog` — `boom`, `check-merged`, `cross-ref`, `report`. -3. `mem:scripts/gh` — `issues`, `issue`, `comments`, `prs`. + layout, entry rules, the `## Breaking change note` comment contract, the + advisory (`:lock:`) contract, pre-flight checks, anomaly types, and pitfalls. +2. `mem:scripts/changelog` — `boom`, `security`, `check-merged`, `cross-ref`, + `report`. +3. `mem:scripts/gh` — `issues`, `issue`, `comments`, `prs`, `advisories`. ## Commands ```bash python3 scripts/gh.py issues "" --exclude "release blocker,no changelog" +python3 scripts/gh.py advisories --patched-version "" python3 scripts/changelog.py check-merged python3 scripts/changelog.py boom "" # print the :boom: section python3 scripts/changelog.py boom "" --write # replace it in CHANGES.md +python3 scripts/changelog.py security "" # print the :lock: section +python3 scripts/changelog.py security "" --write # replace it in CHANGES.md python3 scripts/changelog.py cross-ref "" python3 scripts/changelog.py report "" ``` @@ -48,5 +52,6 @@ python3 scripts/changelog.py report "" - `CHANGES.md` is edited only on explicit request or during the release process. - Only closed, non-excluded milestone issues are included; the issue is the changelog unit and the PR is inline. -- `:boom:` and `:rocket:` are label-generated: never hand-edit them; edit the label or the `## Breaking change note` comment and re-run the command. +- `:boom:` and `:rocket:` are label-generated and `:lock:` is advisory-generated: never hand-edit them; edit the label, the `## Breaking change note` comment or the advisory and re-run the command. - Verify PR merge status before writing; only merged PRs are referenced. +- Fill `patched_versions` in the advisories before running `security`; unpublished advisories are valid. diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index c0667250f1..4cd1658906 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -25,12 +25,13 @@ - Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read. - File/project role rows only count with a live team membership: the file/project permission queries require a matching `team_profile_rel` row, and leaving or removing a member deletes their team-scoped `file`/`project`/`team-project-profile-rel` rows (GHSA-v9r9-h77c-55m2). - File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics. -- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. +- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. RPCs that accept `share-id` enforce the link's page scope before mutating (`share-link-pages` + `check-page-scope`): a thread or page outside the link raises `:not-found`, and that check runs before the ownership checks. `get-profiles-for-file-comments` filters its query by the same pages. `mark-all-threads-as-read` does not take `share-id`. ## Teams, projects, and invitations - Team/project commands mix DB changes, email, message bus notifications, media/storage cleanup, feature flags, quotas, and audit metadata. Keep mutations transactional when the existing command does so. - Invitation flows validate muted/bounced emails before sending and use tokenized invitation state. Accepting an invitation is tied to the invited member identity, not just possession of a token. +- The whole invitation family is team-admin only: creating, updating and deleting invitations, minting the invitation token (`get-team-invitation-token`) and listing the pending ones (`get-team-invitations`, which carries the invited email and the offered role). The token of an invitation that grants the `owner` role is restricted to the team owner, because an admin cannot create nor promote an owner invitation (`cant-promote-to-owner`). This family is the exception to the rule above: it answers `:validation` / `:insufficient-permissions` instead of `:not-found`, so the five commands speak one language. - Logical deletion is used for many product objects; prefer existing logical-deletion helpers over hard deletes unless the command already performs permanent cleanup. - Bounced/spam-complaint emails can mute/block a profile for login/registration and email sending. Devenv Mailpit is the normal local path for registration/email-flow testing. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index f7bdc3020a..fa0def492b 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -84,6 +84,8 @@ - The `media-storage-bytes-per-team` quota check on `upload-file-media-object` is TEMPORARILY DISABLED (2026-10-19, kept commented in `app.rpc.commands.media` with its `sql:get-team-id-for-file` lookup still active for the audit props): the quote performs a full `SUM` over `storage_object` per upload and was too slow in production. The `upload-sessions-per-profile` quota is unaffected and stays active. Re-enable only with a cheaper approximation. - Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. - SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. +- The SVG sanitizer (`app.media.svg`) is a **blocklist**, not an allowlist: it drops dangerous elements and attributes by name and escapes every text and attribute value when it writes the tree back, so a payload hidden in a value cannot become live markup again. The allowlist rework is [#12190](https://github.com/penpot/penpot/issues/12190) and lands in media-processor, not here. `sanitize-svg` also tracks the namespace bindings through the recursion and drops any element that is not in the SVG namespace, so a document whose root is not an `svg` element in the SVG namespace is rejected and a child that rebinds `xmlns` to XHTML (`iframe`/`srcdoc`/`meta`) is dropped with its subtree; it compares the scheme of an URL the way the browser does (dropping tab/CR/LF/spaces), so `java script:` does not slip through the `javascript:` filter. The paths that store an uploaded SVG as its final object go through `app.media.svg/sanitize-svg-upload` (media upload, the URL download, both thumbnail writers and the organization logo); the three binfile imports go through `app.binfile.common/sanitize-imported-svg`, which calls `sanitize-svg` directly. The profile picture is not an SVG: `import-profile-picture` ignores an `image/svg+xml` download, because the avatar is always rendered as an image. The caller checks the size before calling it: the helper reads the whole file, and the size limit applies to the upload, not to the sanitized copy, which can be up to six times bigger. +- Upload handlers run the `media.v` checks in a fixed order: `check-upload-path`, then `check-media-type`, then `check-media-size` (or `check-font-size` for fonts). The path check gates every later read: `check-media-type` sniffs the bytes at `:path` and `check-media-size` reads `fs/size`, so a path the client named outside `allowed-upload-prefixes` must be rejected before either touches the disk. `check-media-size` leans on that and answers a file it cannot read with the path code (`:params-validation`), not `:media-max-file-size-reached`. Keep the order when touching or adding a handler; two branches that reorder these lines conflict. - Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. - Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. - Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index fd5df5962b..14471e7195 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -7,6 +7,7 @@ - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. - The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its header paints the milestone of the job (the stage and its counter) instead of the messages the old SSE stream sent; every file of a container reports the same milestone, so `jp/current-milestone-text` writes it once and the rows never repeat it. Before the job exists, the header paints a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and it writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, no row says anything: the header says the import waits for a slot while no job of it has started. The bar of the header measures the whole import, never a file: indeterminate while checking, waiting for a slot or rebuilding, because the client cannot know a global index and total of operations. The upload is the exception — the chunked upload knows its chunks, so the bar is determinate while the containers are sent, weighted by their size (`imp/upload-pct` over `:container-size`, the only percentage the client really knows). The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line on the rows any more: the rows of the import wizard carry only what is not progress — the name, the size, `Ready` once their container finished uploading, and the error text —, and the button that drops a file only shows while the wizard still offers its import step (the confirmation screen). Above the rows, the import wizard paints a stepper (Check/Upload/Import) plus one phase line: `imp/import-step` derives the step from the dialog status and the entries (upload holds while any file is pending, either `:import-ready` or carrying its own `:upload` milestone), so Check covers analyze, Upload covers the chunked client-side upload, and Import covers queue plus job. A file of a version 3 package shows what it occupies inside its container, computed while analyzing (`imp/file-sizes`): its own `files/…` entries plus the storage objects its media and thumbnails reference, whose metadata is the only link to `objects/…`. The container's own size stays in `:container-size` (what the dropped file weighs) and is what the upload bar weighs, never what a row shows. A file the manifest lists but the container holds no entry of has no size at all, and its row then shows no subtitle: what the package weighs never stands in for it. The subtitle is the size alone, with no extension. While analyzing with no failures nothing renders below the phase header — no rows, no spinner (`imp/awaiting-analysis?`); the header text is the only "Checking…" message. Rows appear once the analysis resolves (one per file inside the package), and a failed analysis takes the error-summary path instead. The ready step and its Continue wait for the end of the analysis (`imp/ready-to-import?`, fed with the completion of `imp/analyze`): a ready file is not enough, because the files still to arrive would never be imported. While rebuilding, the phase title counts the files and `imp/imported-count` counts them done with the job's own `:files` counter, so the number moves while a container rebuilds its files one by one. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. +- The pending invitations are a team-admin only list (backend `:is-admin` already includes the owner). The dashboard hides the invitations entry from the header tabs and the team-options dropdown for non-admins, `invitations-section-content` keys the section on `:is-admin`, and `fetch-invitations` answers `insufficient-permissions` by emitting `invitations-fetched` with `[]`, so a demoted admin does not keep the cached emails and the header does not paint the invite button from a stale list. The organization `send-invitations` policy only gates inviting and resending, not the list. ## Viewer diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index a13d2a2946..8ca829c32f 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -57,7 +57,7 @@ mcp/ │ ├── src/ │ │ ├── main.ts # handles communication │ │ └── plugin.ts # plugin implementation -│ └── package.json # Includes @penpot-mcp/common dependency +│ └── package.json # Plugin deps: published plugin-styles, linked plugin-types └── prepare-api-docs # Python project for the generation of API docs ``` @@ -107,6 +107,13 @@ From the `mcp/` directory, run (node reports `Promise resolution is still pending but the event loop has already resolved` for the file's root test). Inside tests the logger writes to stdout directly. `NODE_TEST_CONTEXT` is the env var `node --test` sets. +* `packages/plugin` takes `@penpot/plugin-styles` from the registry but links + `@penpot/plugin-types` to `plugins/libs/plugin-types` (`link:` in the manifest + and in `mcp/pnpm-lock.yaml`, with no registry entry, because a linked package + has no resolution): the published types do not declare the `penpotMgmt` + global the plugin needs. On a plugins libs version bump, only the styles + version moves. After editing the manifest, `pnpm install --lockfile-only` in + `mcp/` must leave the lock untouched. * Cross-cutting testing principles and anti-patterns: `mem:testing`. ## OAuth for remote MCP clients diff --git a/.serena/memories/scripts/changelog.md b/.serena/memories/scripts/changelog.md index e04036ea9a..7134377fa5 100644 --- a/.serena/memories/scripts/changelog.md +++ b/.serena/memories/scripts/changelog.md @@ -6,7 +6,8 @@ - `check-merged ` (`--file`, `--stdin` accepted) — warn on any non-merged PR, exit 1 when found; run before writing changelog entries. - `boom [--changes CHANGES.md --write]` — generate `### :boom: Breaking changes & Deprecations` from the closed, non-excluded milestone issues labelled `breaking change`, sorted by issue number. Each issue becomes a `#### ` block: content = body of the issue's last `## Breaking change note` comment (whole markdown, headings demoted to `#####` outside code fences; fallback = heading plus refs with a stderr warning), refs = issue link plus merged closing PRs on their own line, with `(by @author)` for `community contribution`. `--write` replaces only that subsection of the version (inserts it after the version header when missing, keeps the heading when there are no entries; idempotent). Print-only by default. Uses `gh.py comments`. -- `cross-ref [--changes CHANGES.md]` — fetch milestone PRs with `--state all` once, report merged PRs missing from the version section plus CLOSED (unmerged) warnings; GHSA entries never appear here by design. +- `security [--changes CHANGES.md --write]` — generate `### :lock: Security fixes` from the advisories whose `patched_versions` include the version (via `gh.py advisories --patched-version`), sorted by GHSA id. Entry: `- Fix () (PR: [#N](...))`; description = `Fix` + advisory summary, first word decapitalized unless it starts with an acronym; PRs = merged milestone PRs labelled `pr pt` whose body contains the GHSA id, comma-separated, and no PR is legal. Warns on stderr when a `pr pt` PR references an advisory with no matching `patched_versions`. `--write` replaces only that subsection, placed after `:rocket:` (or `:boom:`, or the version header when neither exists); empty entries remove the subsection; idempotent. Print-only by default. +- `cross-ref [--changes CHANGES.md]` — fetch milestone PRs with `--state all` once, report merged PRs missing from the version section plus CLOSED (unmerged) warnings. PR links inside `:lock:` entries count as referenced; advisory URLs never appear here by design. - `report [--changes CHANGES.md --output CHANGES-ISSUES.md]` — overwrite the anomaly report: A/B milestone mismatches, E `:boom:` entry without the label, F `:rocket:` sync, G `:boom:` sync, plus the C/D `:rocket:` gaps (warnings). `:boom:`/`:rocket:` rule violations are fixed by regenerating or relabelling, not by editing entries in place. ## Conventions diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index 93fd4e37b8..c2a10d8f6b 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -102,7 +102,7 @@ python3 scripts/gh.py link-issue # Short alias: python3 scripts/gh.py link ``` -The command resolves both node IDs and calls `addCloseIssueReferences`. It trusts the successful mutation instead of re-querying, because GitHub does not reliably report mutation-created links through `closedByPullRequestsReferences(userLinkedOnly: true)`. It is safe to rerun, works for merged PRs, and does not close an issue retroactively. JSON goes to stdout; progress and errors go to stderr; a missing issue/PR or a failed mutation exits non-zero. +The command resolves both node IDs and calls `addCloseIssueReferences`. It trusts the successful mutation instead of re-querying, because GitHub does not reliably report mutation-created links through `closedByPullRequestsReferences(userLinkedOnly: true)`. It is safe to rerun and works for merged PRs. It does not guarantee the issue stays open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09). JSON goes to stdout; progress and errors go to stderr; a missing issue/PR or a failed mutation exits non-zero. ### `advisories` @@ -118,6 +118,9 @@ python3 scripts/gh.py advisories --severity critical # Filter by state python3 scripts/gh.py advisories --state triage +# Filter by patched version declared in the advisory +python3 scripts/gh.py advisories --patched-version 2.19.1 + # Get full detail for a single advisory python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 ``` @@ -126,6 +129,8 @@ python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 **Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps. +`--patched-version` filters the raw advisories before formatting: the match is an exact token of `vulnerabilities[*].patched_versions` (split on `,`/`;`), so `2.19.1` never matches `2.19.10`. Every state is included (`triage`, `draft`, `published`, `closed`, `withdrawn`). The raw `vulnerabilities` stay internal to the filter: the list output keeps the summary schema. + **Output**: JSON to stdout; progress to stderr. ## Key principles diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 537e3e73ea..2275cc9d8d 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -60,6 +60,7 @@ the issue (`scripts/changelog.py boom`), so the issue must carry both: Post the note as a new comment. Do not add a breaking-change section to the issue body and do not hand-write the changelog block. When the issue comes from a PR, reuse the text of the PR's breaking-change section. +Do not hard-wrap the note: one line per paragraph or bullet. The full changelog rules (sections, exclusions, `:rocket:`, pre-flight and anomaly report) live in `mem:workflow/updating-changelog`. @@ -314,7 +315,7 @@ Add `Closes #` to the PR body for readable context, then run the e python3 scripts/gh.py link-issue ``` -The command creates the GitHub Development link by calling `addCloseIssueReferences` and trusts the successful mutation (GitHub does not reliably report mutation-created links back through the API). It is safe to rerun and does not close an issue retroactively when the PR is already merged. Do not rely on the body keyword as the assignment operation. +The command creates the GitHub Development link by calling `addCloseIssueReferences` and trusts the successful mutation (GitHub does not reliably report mutation-created links back through the API). It is safe to rerun. Do not rely on it to keep the issue open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09; the timeline shows a cross-reference plus a ClosedEvent with no closer). For changelog units that outcome is fine — a merged fix means a closed issue — but be aware when linking old merged PRs. Do not rely on the body keyword as the assignment operation. ### Clean up diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4f2d2bcfa7..87f10f2121 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -80,7 +80,7 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. ## Explicit Issue Assignment - For each GitHub issue that a PR resolves, run `python3 scripts/gh.py link-issue ` after creating or editing the PR. Do not rely on `Closes #NNNN` in the body; it is only human-readable context. -- The command calls `addCloseIssueReferences` and trusts the successful mutation: GitHub does not reliably report mutation-created links back through `closedByPullRequestsReferences(userLinkedOnly: true)`, so the command exits non-zero only when a link target is missing or the mutation fails. It is safe to rerun and also works for an already merged PR; it does not close an issue retroactively. +- The command calls `addCloseIssueReferences` and trusts the successful mutation: GitHub does not reliably report mutation-created links back through `closedByPullRequestsReferences(userLinkedOnly: true)`, so the command exits non-zero only when a link target is missing or the mutation fails. It is safe to rerun and also works for an already merged PR. Do not rely on it to keep the issue open: adding `Closes #N` to the body of an already-merged PR closed the issue immediately when tried (#12896 / PR #12067, 2026-11-09). - Skip this process for `Relates to #NNNN` and Taiga references, which do not represent a closing relationship. ## Before Opening diff --git a/.serena/memories/workflow/updating-changelog.md b/.serena/memories/workflow/updating-changelog.md index 26ac7624af..54ca604790 100644 --- a/.serena/memories/workflow/updating-changelog.md +++ b/.serena/memories/workflow/updating-changelog.md @@ -2,7 +2,7 @@ > Note about this memory: unlike other Serena memories (dense notes written for LLM agents, see `mem:memory-maintenance`), this document is also written for human release managers who need full context. That is why it uses plain language and examples. It is the canonical home of the changelog flow; the `update-changelog` skill is only an entry point that routes here. -`CHANGES.md` (repository root) is the release notes file. Every entry comes from GitHub: a milestone issue, its merged PR, or a security advisory. It is never written from scratch by hand; the only manual parts are the security advisory entries and the wording of a description when the title is not enough. +`CHANGES.md` (repository root) is the release notes file. Every entry comes from GitHub: a milestone issue, its merged PR, or a security advisory whose `patched_versions` include the version. It is never written from scratch by hand; the only manual parts are the wording of a description when the title is not enough and the advisory summaries (polished on GitHub, the source of the generated security entries). The commands live in `scripts/gh.py` (GitHub queries) and `scripts/changelog.py` (changelog checks). Their flags and internals are documented in `mem:scripts/gh` and `mem:scripts/changelog`. @@ -19,11 +19,13 @@ Editing `CHANGES.md` happens only on explicit request or as part of the release | Question | Source | |---|---| | Which issues belong to the version? | GitHub milestone | +| Which security fixes? | Advisories whose `patched_versions` include the version | | Which section? | Issue Type | | Is it a breaking change? | `breaking change` label | | Is it a highlight? | `release highlight` label | - **Issue Type decides the section**: `Bug` → `:bug:`, `Feature`/`Enhancement` → `:sparkles:`, `Task` is excluded (internal chores). Labels `bug`/`enhancement` and title emoji prefixes are often wrong or missing; do not use them. +- **Security fixes come from advisories**: an advisory whose `patched_versions` include the version becomes a `:lock:` entry (section 7). `triage`, `draft` and `published` are all valid states; the advisory may still be unpublished when the changelog is written. A security fix may also carry a regular bug issue; both entries are legal and independent (the issue lives in `:bug:` as any other bug). - **Two labels override the Issue Type**: `breaking change` → an entry only under `:boom:`, never in its regular section; `release highlight` → an extra entry under `:rocket:` on top of its regular entry. - **Only closed issues**. An open issue in the milestone is not generated, even if someone added a provisional entry by hand before the merge. - **Excluded issues**: `no changelog`, `release blocker`, `Task` issue type, `Rejected` project status. @@ -51,6 +53,10 @@ The newest version goes at the top, right after the `# CHANGELOG` header. Inside - Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237)) +### :lock: Security fixes + +- Fix () (PR: [#PR](https://github.com/penpot/penpot/pull/PR)) + ### :bug: Bugs fixed - Fix [#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR)) @@ -60,8 +66,8 @@ The newest version goes at the top, right after the `# CHANGELOG` header. Inside - Add [#ISSUE](https://github.com/penpot/penpot/issues/ISSUE) (PR: [#PR](https://github.com/penpot/penpot/pull/PR)) ``` -- `:boom:` first, then `:rocket:`, `:bug:`, `:sparkles:`. Other headings exist in old versions (`:heart:` community contributions); do not add them to new versions. -- Include a section only when it has entries. Historical versions may have an empty `:boom:` or `:rocket:` heading; `boom --write` keeps the `:boom:` heading even when there are no entries. +- `:boom:` first, then `:rocket:`, `:lock:`, `:bug:`, `:sparkles:`. Other headings exist in old versions (`:heart:` community contributions); do not add them to new versions. +- Include a section only when it has entries. Historical versions may have an empty `:boom:` or `:rocket:` heading; `boom --write` keeps the `:boom:` heading even when there are no entries. `:lock:` is omitted (and removed on `--write`) when the version has no security advisories. - One blank line between the last entry of a section and the next heading. - No manual line wrapping: one line per bullet or paragraph, however long. @@ -116,11 +122,12 @@ Rules for the note: - **Write it for admins**: what changes, what they must do, and what breaks if they do nothing. Do not paste the full runbook; explain the implications and link the runbook or docs. - **Link a long-lived branch**, `staging` or `main`, never a feature branch (`issue-1234`). The link may 404 until the PR merges; that is expected. - **Do not add issue or PR links**: the generator appends them. +- **Do not hard-wrap the text**: one line per paragraph or bullet. The generator copies lines verbatim, so wrapped lines end up wrapped in `CHANGES.md`. - **One note per issue**. If several comments carry the marker, the generator uses the last one and warns. - **No note**: the generator warns on stderr and emits only the heading and references; fix the issue, not `CHANGES.md`. - **`####`, not `###`**: `###` is the level of the changelog sections. An entry at `###` would render as a sibling section and the scripts that read and rewrite `:boom:` would cut the section there. The note author never writes the `####`: the generator adds it. - **Only merged closing PRs** are referenced; unmerged or superseded PRs are skipped. -- **Never hand-edit the section**. Generate it with `boom` (section 7). The note lives on GitHub; edit it there. +- **Never hand-edit the section**. Generate it with `boom` (section 8). The note lives on GitHub; edit it there. ## 6. `:rocket:`: epics and highlights @@ -129,35 +136,48 @@ Rules for the note: - If no issue is labelled, leave the section out; do not pick highlights yourself. - Never edit `:rocket:` by hand: add or remove the label and regenerate. -## 7. Flow +## 7. `:lock:`: security fixes + +Generated from the advisories that declare the version in `patched_versions`; never written by hand. + +- **Source and filter**: `python3 scripts/gh.py advisories --patched-version ` keeps the advisories whose `vulnerabilities[*].patched_versions` contain the version as an exact token (so `2.19.1` never matches `2.19.10`). Any state is valid — `triage`, `draft`, `published` — because the advisory may still be unpublished when the changelog is written. +- **Fill the advisories first**: `patched_versions` must be filled before generating the changelog. The release process publishes the advisories with (or after) the release; the changelog does not wait for publication. +- **PR reference**: a merged PR in the milestone labelled `pr pt` whose body contains the GHSA id contributes its number inline; several PRs go comma-separated. An advisory with no PR is legal: the entry links only the advisory. +- **Missing `patched_versions`**: when a `pr pt` PR references an advisory that declares no matching `patched_versions`, `security` prints a warning on stderr and leaves it out. Fix the advisory, not `CHANGES.md`. +- **Entry format**: `- Fix () (PR: [#PR](...))`. The description is `Fix` plus the advisory summary, with the first word decapitalized unless it starts with an acronym (`SVG`, `CVE-...`). +- **Wording**: the flow may polish the description. Re-running `security --write` rewrites it from the summary, so the durable place for wording is the advisory summary on GitHub; keep it a problem statement, because the generator adds `Fix`. +- **Empty version**: no matching advisories → the subsection is not inserted (and is removed by `--write`). +- **Never hand-edit the section**: generate it with `security` (section 8). + +## 8. Flow 1. **Confirm the version** (a semver string such as `2.19.0`) with the user when it is not obvious. 2. **Fetch the milestone issues**: `python3 scripts/gh.py issues "" --exclude "release blocker,no changelog"` (closed by default; add `--state all` only to inspect open issues). `gh.py` already drops `Task` and `Rejected`; pass `--include-tasks` / `--include-rejected` only to inspect them. To find what is missing from an existing section, add `--compare CHANGES.md`. 3. **Fetch PR details when needed**: `python3 scripts/gh.py prs ` (also `--milestone`). Use it for the PR author, labels and merge state. 4. **Categorize** by Issue Type and the two labels (section 2), and **build the descriptions** (section 4). 5. **Verify PR merge status** before writing: `python3 scripts/changelog.py check-merged `. Only merged PRs go in the changelog; if a closing PR was closed unmerged, look for the PR that superseded it. -6. **Reconcile the existing text** (section 8). +6. **Reconcile the existing text** (section 9). 7. **Generate `:boom:`**: `python3 scripts/changelog.py boom ""` prints the section; add `--write` to replace it in `CHANGES.md`. The write replaces only that subsection, inserts it after the version header when missing, keeps the heading when there are no entries, and is idempotent. 8. **Generate `:rocket:`** from the `release highlight` label (section 6). -9. **Cross-check PRs**: `python3 scripts/changelog.py cross-ref ""`. It lists merged milestone PRs missing from the section and warns about closed (unmerged) PRs. GHSA entries never appear; that absence is expected. -10. **Generate the anomaly report**: `python3 scripts/changelog.py report ""`. It overwrites `CHANGES-ISSUES.md` with the current state (section 9). -11. **Security advisories (GHSA)**: add them by hand under `:bug:` as `- Fix ()`, with no issue or PR link. Do not fetch or verify the URL: it may be draft and return 404. Write the description in imperative mood from the advisory title. These entries are invisible to the automation; apply only the duplicate/backport check to them. +9. **Generate `:lock:`**: `python3 scripts/changelog.py security ""` prints the section; add `--write` to replace it in `CHANGES.md`. The contract lives in section 7. +10. **Cross-check PRs**: `python3 scripts/changelog.py cross-ref ""`. It lists merged milestone PRs missing from the section and warns about closed (unmerged) PRs. PRs linked from `:lock:` count as referenced; an advisory with no PR is invisible here by design. +11. **Generate the anomaly report**: `python3 scripts/changelog.py report ""`. It overwrites `CHANGES-ISSUES.md` with the current state (section 10). -## 8. Pre-flight: reconcile before writing +## 9. Pre-flight: reconcile before writing Re-fetch the milestone (do not trust cached data) and fix these in `CHANGES.md` before adding the new section: -1. **Duplicate across versions** → remove it from the current section; the earlier version wins for backports. +1. **Duplicate across versions** → remove it from the current section; the earlier version wins for backports. The same holds after merging a patch branch into `staging`: the fix keeps its released patch entry and leaves the newer, unreleased section (the merge conflict in `CHANGES.md` is resolved by keeping both sections, newest first, minus the duplicate). 2. **Stale milestone assignment** (the issue moved out of this milestone) → remove the entry. 3. **Newly applied exclusion labels** (`no changelog`, `release blocker`) → remove the entry. 4. **Issue no longer closed, deleted, or now `Rejected`** → remove the entry. -5. **Unmerged or moved PR reference** → fix the reference or remove the entry. A PR merged in a different milestone is an anomaly (section 9); do not remove it silently. +5. **Unmerged or moved PR reference** → fix the reference or remove the entry. A PR merged in a different milestone is an anomaly (section 10); do not remove it silently. 6. **Issue Type changed to `Task`** → remove the entry. 7. **Breaking change out of sync** (a `breaking change` issue outside `:boom:`, or a `:boom:` entry without the label) → regenerate the section; a labelled issue has no regular entry. 8. **Highlight out of sync** (`:rocket:` entry without the label, or a labelled issue missing) → remove it or add it. 9. **Missing valid issues** (closed, non-excluded, referenced nowhere) → add them to their section. -## 9. Anomaly report +## 10. Anomaly report `report` classifies problems it cannot decide alone. The total counts A, B, E, F and G; C and D are warnings. @@ -171,17 +191,18 @@ Re-fetch the milestone (do not trust cached data) and fix these in `CHANGES.md` Anything else is a rule violation, not a report item: fix it in the pre-flight and re-run. -## 10. Pitfalls +## 11. Pitfalls - **Open issues with provisional entries**: a PR author may add the entry before the merge. `boom` will not generate it until the issue closes; keep the hand-written block identical to what the generator would produce. - **Labels, not emoji**: title prefixes are cosmetic and often missing; the section comes from Issue Type and the two labels. - **Taiga references**: old entries link to Taiga. Resolve them to the GitHub issue via the description text or the PRs that mention the Taiga URL, then link issue and PR. - **Long-lived links only**: runbooks and docs referenced from a note must point to `staging` or `main`, never to a feature branch that will be deleted. -- **Generated sections**: `:boom:` and `:rocket:` are the only label-generated parts. Never patch them by hand; edit the label or the note comment and re-run. +- **Generated sections**: `:boom:`, `:rocket:` and `:lock:` are generated. Never patch them by hand; edit the label, the note comment or the advisory and re-run. +- **Deleted or transferred issues**: an issue referenced in the changelog that returns 410/404 (deleted or moved out of the repo) must lose its entry in the pre-flight. Its security fixes, if any, stay in `:lock:` through the advisory. -## 11. Where the code is +## 12. Where the code is -- `scripts/gh.py`: `issues`, `issue`, `comments`, `prs`, `link-issue`, `advisories`. Details: `mem:scripts/gh`. -- `scripts/changelog.py`: `check-merged`, `boom`, `cross-ref`, `report`. Details: `mem:scripts/changelog`. +- `scripts/gh.py`: `issues`, `issue`, `comments`, `prs`, `link-issue`, `advisories` (with `--patched-version`). Details: `mem:scripts/gh`. +- `scripts/changelog.py`: `check-merged`, `boom`, `security`, `cross-ref`, `report`. Details: `mem:scripts/changelog`. - Tests: `scripts/test_gh.py`, `scripts/test_changelog.py`. - The note contract for issue authors: `mem:workflow/creating-issues`, section **Breaking changes**. diff --git a/CHANGES.md b/CHANGES.md index e550684ffd..e1aa8dc7b9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,78 @@ # CHANGELOG +## 2.20.0 + +### :bug: Bugs fixed + +- Fix dashboard file thumbnails loading slow for large multi-page files [#12053](https://github.com/penpot/penpot/issues/12053) (PR: [#12054](https://github.com/penpot/penpot/pull/12054)) +- Fix Plugin API resolving a font family token to its separate words when the family name has several words [#11748](https://github.com/penpot/penpot/issues/11748) (PR: [#11750](https://github.com/penpot/penpot/pull/11750)) +- Fix asset redirect responses advertising a cache lifetime of about 1000 days instead of 24 hours (by @ners) [#12761](https://github.com/penpot/penpot/issues/12761) (PR: [#12758](https://github.com/penpot/penpot/pull/12758)) +- Fix an internal error on typography tokens due to circular dependencies on other token types [#9580](https://github.com/penpot/penpot/issues/9580) +- Fix the MCP server rejecting request bodies over 100kb (express.json default) with PayloadTooLargeError (by @GitNimay) [#12080](https://github.com/penpot/penpot/issues/12080) (PR: [#12450](https://github.com/penpot/penpot/pull/12450)) +- Fix library component, color and typography counters showing zero when opening files from the dashboard (by @lukel97) [#12826](https://github.com/penpot/penpot/issues/12826) (PR: [#12787](https://github.com/penpot/penpot/pull/12787)) +- Fix workspace crash with Maximum call stack size exceeded when applying a library update [#12576](https://github.com/penpot/penpot/issues/12576) (PR: [#12661](https://github.com/penpot/penpot/pull/12661)) +- Fix canvas rendering an imported shape without its hole when the SVG fill rule is evenodd [#11947](https://github.com/penpot/penpot/issues/11947) (PR: [#12006](https://github.com/penpot/penpot/pull/12006)) +- Fix Plugin API component.instance() and createShapeFromSvg() nesting the new shape into whatever board is at (0, 0) of the current page (by @makesomethingshit) [#12086](https://github.com/penpot/penpot/issues/12086) (PR: [#12499](https://github.com/penpot/penpot/pull/12499)) +- Fix view mode breaking zoom, board navigation, and comments when the URL has no page id [#12684](https://github.com/penpot/penpot/issues/12684) (PR: [#12687](https://github.com/penpot/penpot/pull/12687)) +- Fix autosave stopping and valid edits being lost when part of a save fails validation [#12553](https://github.com/penpot/penpot/issues/12553) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Fix dashboard duplicate copying a file without the edits that are still saving [#12603](https://github.com/penpot/penpot/issues/12603) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Fix Alt measurements still showing a shape that was deleted [#12605](https://github.com/penpot/penpot/issues/12605) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Fix Plugin API ignoring changes to a deleted shape without an error [#12606](https://github.com/penpot/penpot/issues/12606) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Fix typography sample overflowing its row in locales with a wider sample (ko, ar) (by @desty) [#11486](https://github.com/penpot/penpot/issues/11486) (PR: [#11487](https://github.com/penpot/penpot/pull/11487)) +- Fix token not showing when selecting two text layers (by @ShreyashAgare26) [#11924](https://github.com/penpot/penpot/issues/11924) (PR: [#11944](https://github.com/penpot/penpot/pull/11944)) +- Fix inner shadow fill not working in the SVG render while it works in WebGL [#11967](https://github.com/penpot/penpot/issues/11967) (PR: [#12141](https://github.com/penpot/penpot/pull/12141)) +- Fix @penpot/plugin-styles loading Work Sans from a third-party CDN (by @ump45nose) [#12023](https://github.com/penpot/penpot/issues/12023) (PR: [#12087](https://github.com/penpot/penpot/pull/12087)) + +### :sparkles: New features & Enhancements + +- Add a feedback channel to the MCP server [#12490](https://github.com/penpot/penpot/issues/12490) (PR: [#12551](https://github.com/penpot/penpot/pull/12551)) +- Auto-scroll sortable lists when dragging an item near their top or bottom edge (by @ShreyashAgare26) [#10478](https://github.com/penpot/penpot/issues/10478) (PR: [#11595](https://github.com/penpot/penpot/pull/11595)) +- Make WebGL the default renderer for new accounts [#11855](https://github.com/penpot/penpot/issues/11855) (PR: [#12880](https://github.com/penpot/penpot/pull/12880)) +- Allow MCP to work with multiple open Penpot files [#11759](https://github.com/penpot/penpot/issues/11759) +- Complete the Russian translations [#11955](https://github.com/penpot/penpot/issues/11955) (PR: [#11956](https://github.com/penpot/penpot/pull/11956)) +- Implement full text format paste within Penpot [#12047](https://github.com/penpot/penpot/issues/12047) (PR: [#12137](https://github.com/penpot/penpot/pull/12137)) +- Prevent the hosted MCP server from running execute_code twice when the plugin is open in two tabs [#12008](https://github.com/penpot/penpot/issues/12008) (PR: [#11927](https://github.com/penpot/penpot/pull/11927)) +- Allow detaching typography tokens [#11887](https://github.com/penpot/penpot/issues/11887) (PR: [#11969](https://github.com/penpot/penpot/pull/11969)) +- Add team, project and file management to the Plugin API for global plugins [#12170](https://github.com/penpot/penpot/issues/12170) (PR: [#12169](https://github.com/penpot/penpot/pull/12169)) +- Run .penpot file import and export as durable background jobs [#12194](https://github.com/penpot/penpot/issues/12194) (PR: [#12056](https://github.com/penpot/penpot/pull/12056), [#12192](https://github.com/penpot/penpot/pull/12192)) +- Open files and projects in a new tab with Ctrl/Cmd+click in the dashboard [#12228](https://github.com/penpot/penpot/issues/12228) (PR: [#12230](https://github.com/penpot/penpot/pull/12230)) +- Add an RPC method to read file-level plugin data [#12258](https://github.com/penpot/penpot/issues/12258) (PR: [#10171](https://github.com/penpot/penpot/pull/10171)) +- Add a get-file-tokens RPC method to read a file's design tokens [#12260](https://github.com/penpot/penpot/issues/12260) (PR: [#12172](https://github.com/penpot/penpot/pull/12172)) +- Show the progress and the size of each file in the import and export dialogs [#12340](https://github.com/penpot/penpot/issues/12340) (PR: [#12192](https://github.com/penpot/penpot/pull/12192)) +- Focus the search field when opening the color tokens tab of the color picker [#12344](https://github.com/penpot/penpot/issues/12344) (PR: [#12345](https://github.com/penpot/penpot/pull/12345)) +- Harden the file data offload job with a row lock, per-file transactions, throttle and metrics [#12398](https://github.com/penpot/penpot/issues/12398) (PR: [#12403](https://github.com/penpot/penpot/pull/12403)) +- Render email templates inside the mail job in the backend [#12445](https://github.com/penpot/penpot/issues/12445) (PR: [#12448](https://github.com/penpot/penpot/pull/12448)) +- Serve Penpot under a subpath [#3194](https://github.com/penpot/penpot/issues/3194) (PR: [#12358](https://github.com/penpot/penpot/pull/12358)) +- Run local WASM exports for shapes and boards without freezing the UI [#12512](https://github.com/penpot/penpot/issues/12512) (PR: [#12515](https://github.com/penpot/penpot/pull/12515)) +- Use WASM to generate SVG markup in the code inspector when render-wasm is active [#12567](https://github.com/penpot/penpot/issues/12567) (PR: [#12608](https://github.com/penpot/penpot/pull/12608)) +- Close the workspace search with the Escape key (by @filipsajdak) [#9540](https://github.com/penpot/penpot/issues/9540) (PR: [#12374](https://github.com/penpot/penpot/pull/12374)) +- Allow MCP to access and manage Penpot Dashboard resources [#11761](https://github.com/penpot/penpot/issues/11761) +- Add OAuth support for MCP client connections [#12034](https://github.com/penpot/penpot/issues/12034) (PR: [#12480](https://github.com/penpot/penpot/pull/12480)) +- Restrict management RPC methods to their declared shared keys [#12730](https://github.com/penpot/penpot/issues/12730) (PR: [#12736](https://github.com/penpot/penpot/pull/12736)) +- Reduce memory used by duplicate shape ids in the workspace [#12791](https://github.com/penpot/penpot/issues/12791) (PR: [#12792](https://github.com/penpot/penpot/pull/12792)) +- Reduce memory consumption on shape decoding [#12789](https://github.com/penpot/penpot/issues/12789) (PR: [#12790](https://github.com/penpot/penpot/pull/12790)) +- Notify other users when a snapshot is restored [#12425](https://github.com/penpot/penpot/issues/12425) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Normalize the configured tenant to lowercase [#12842](https://github.com/penpot/penpot/issues/12842) (PR: [#12843](https://github.com/penpot/penpot/pull/12843)) +- Speed up saves on large files by checking only the pages a save changes [#12602](https://github.com/penpot/penpot/issues/12602) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Retry saves after short server failures and keep the workspace open when a save fails [#12601](https://github.com/penpot/penpot/issues/12601) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) +- Switch the workspace to read-only mode when the file can't be saved [#12507](https://github.com/penpot/penpot/issues/12507) (PR: [#12555](https://github.com/penpot/penpot/pull/12555)) + +## 2.19.1 + +### :lock: Security fixes + +- Fix editor-role team members retrieving the signed invitation token of any pending invitation (https://github.com/penpot/penpot/security/advisories/GHSA-4mmc-j8rh-74pf) (PR: [#12892](https://github.com/penpot/penpot/pull/12892)) + +- Fix share-link page-scope escape in `get-profiles-for-file-comments` leaking the identity of comment authors outside the link's scope (https://github.com/penpot/penpot/security/advisories/GHSA-89cq-3wv7-987r) (PR: [#12888](https://github.com/penpot/penpot/pull/12888)) + +- Fix missing authorization and unbounded array in mark-all-threads-as-read that let any authenticated user write the status of inaccessible threads (https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7) (PR: [#12894](https://github.com/penpot/penpot/pull/12894)) + +- Fix incomplete SVG sanitizer escaping that let a "sanitized" SVG carry live script (CVE-2026-105694) (https://github.com/penpot/penpot/security/advisories/GHSA-qwhj-rchj-58mg) (PR: [#12887](https://github.com/penpot/penpot/pull/12887)) + +### :bug: Bugs fixed + +- Fix component instance (variant) inside a layout resetting to its original size after a shared library update of an unrelated component [#12670](https://github.com/penpot/penpot/issues/12670) (PR: [#12851](https://github.com/penpot/penpot/pull/12851)) + ## 2.19.0 ### :boom: Breaking changes & Deprecations @@ -121,6 +194,7 @@ The Admin Console is now enabled by default and the `enable-admin-console` flag - Fix `:referential-integrity` errors when creating, combining or renaming variant sets [#12734](https://github.com/penpot/penpot/issues/12734) (PR: [#12779](https://github.com/penpot/penpot/pull/12779)) - Fix save errors after masking a variant with Ctrl+M [#12748](https://github.com/penpot/penpot/issues/12748) (PR: [#12749](https://github.com/penpot/penpot/pull/12749)) - Fix icons in component instances not being shown on the canvas [#12752](https://github.com/penpot/penpot/issues/12752) +- Fix upload endpoints accepting non-multipart upload paths and validation error bodies being truncated [#12896](https://github.com/penpot/penpot/issues/12896) (PR: [#12067](https://github.com/penpot/penpot/pull/12067)) ### :sparkles: New features & Enhancements diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 20b6255163..9d1146bf22 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -231,6 +231,7 @@ "delete-file" "delete-font" "delete-organization" + "delete-organization-attribute" "delete-organization-member" "delete-project" "delete-team" diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj index 2ef7112815..6d38011b4d 100644 --- a/backend/src/app/media/svg.clj +++ b/backend/src/app/media/svg.clj @@ -12,8 +12,10 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.storage.tmp :as tmp] [clojure.xml :as xml] - [cuerdas.core :as str]) + [cuerdas.core :as str] + [datoteka.fs :as fs]) (:import clojure.lang.XMLHandler java.io.InputStream @@ -46,15 +48,85 @@ (xml/parse istream secure-parser-factory)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG SERIALIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private xml-declaration + "Declaration written at the top of every sanitized document." + "") + +(defn- escape-text + "Escape the characters that are special inside a XML text node." + [s] + (-> s + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">"))) + +(defn- escape-attr + "Escape the characters that are special inside a XML attribute value. + The value is written between double quotes, so the quote itself is + escaped too. + + Tabs and line breaks become spaces: that is what the XML parser does + with them on the next read, and it keeps the output stable." + [s] + (-> (str s) + (str/replace #"[\t\r\n]" " ") + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """) + (str/replace "'" "'"))) + +(defn- emit-element + "Serialize a parsed element, escaping every text and attribute value. + + The tag and attribute names come from the parser, so they are valid + XML names; only their values can carry markup. + + The attributes are sorted by name: the parser hands them over in the + reverse order of the document, so writing them as they come would + make the output change on every sanitization." + [{:keys [tag attrs content]}] + (let [attrs-str (apply str + (for [[k v] (sort-by (comp name key) attrs)] + (str " " (name k) "=\"" (escape-attr (str v)) "\""))) + content-str (apply str + (for [child content] + (if (map? child) + (emit-element child) + (escape-text child))))] + (if (seq content) + (str "<" (name tag) attrs-str ">" content-str "") + (str "<" (name tag) attrs-str "/>")))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SVG SANITIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private dangerous-attrs-pattern #"(?i)^on\w+$") (def ^:private javascript-href-pattern #"(?i)^javascript:") +(def ^:private url-ignored-chars + "Characters that the URL parser discards before it interprets the + scheme of an URL." + #"[\u0000-\u0020\u007f]") + +(defn- javascript-url? + "True when the value is a javascript: URL. + + The comparison drops the characters the URL parser drops (tab, CR, LF + and spaces), so a scheme split with one of them (`java script:`) + cannot slip through." + [v] + (let [v (str/replace (str v) url-ignored-chars "")] + (boolean (re-find javascript-href-pattern (str/trim v))))) + (def ^:private dangerous-tags #{"script" "foreignObject" "set" - "animate" "animateTransform" "animateColor" "animateMotion"}) + "animate" "animateTransform" "animateColor" "animateMotion" + "iframe" "meta" "embed" "link" "base"}) (defn- local-name "Return the local name of a parsed tag or attribute keyword. @@ -70,24 +142,82 @@ (last (str/split n ":")) n))) +(def ^:private svg-namespace "http://www.w3.org/2000/svg") + +(defn- namespace-bindings + "Return the namespace bindings in scope for an element: `::default` + plus the keyword prefixes declared in `attrs`, merged over the + inherited ones. The default binding is stored under a namespaced + keyword because a prefix cannot contain `/`, so no prefixed binding + can produce the same key. + + The XML parser does not process namespaces, so the declarations are + plain attributes (`:xmlns`, `:xmlns:svg`) and the prefix is part of + the tag name (`:svg:svg`)." + [attrs inherited] + (let [own (reduce-kv (fn [acc k v] + (let [n (name k)] + (cond + (= n "xmlns") (assoc acc ::default v) + (str/starts-with? n "xmlns:") (assoc acc (keyword (subs n 6)) v) + :else acc))) + {} + attrs)] + (merge inherited own))) + +(defn- element-namespace + "Return the namespace URI of `tag` under `bindings`: the default + namespace when the tag has no prefix, the prefixed binding otherwise. + + The prefix is part of the tag name (`:svg:svg`), so the binding key + is the prefix." + [tag bindings] + (let [n (name tag) + idx (str/last-index-of n ":")] + (if (nil? idx) + (::default bindings) + (get bindings (keyword (subs n 0 idx)))))) + +(defn- svg-root? + "True when the element is an `svg` element in the SVG namespace. + + The namespace is resolved with the same model as the recursion + (`namespace-bindings` + `element-namespace`), so the check does not + introduce a second rule." + [{:keys [tag attrs]}] + (let [bindings (namespace-bindings attrs {})] + (and (= "svg" (local-name tag)) + (= svg-namespace (element-namespace tag bindings))))) + (defn- sanitize-svg-element - "Recursively sanitize an SVG element by removing dangerous tags and attributes." - [{:keys [tag attrs content] :as element}] - (when (and (map? element) tag) - (when-not (contains? dangerous-tags (local-name tag)) - (let [clean-attrs (->> attrs - (remove (fn [[k v]] - (or (re-matches dangerous-attrs-pattern (local-name k)) - (and (= "href" (local-name k)) - (string? v) - (re-find javascript-href-pattern (str/trim v)))))) - (into {})) + "Recursively sanitize an SVG element by removing dangerous tags and + attributes. + + The namespace bindings are tracked through the recursion: an element + that is not in the SVG namespace is dropped with its whole subtree, + because the filter only knows about SVG elements and a child that + rebinds the namespace to XHTML can carry live HTML." + [element bindings] + (let [{:keys [tag attrs content]} element + bindings (namespace-bindings attrs bindings)] + (when (and (map? element) tag + (not (contains? dangerous-tags (local-name tag))) + (= svg-namespace (element-namespace tag bindings))) + (let [clean-attrs (->> attrs + (remove (fn [[k v]] + (let [n (name k)] + (or (and (not (str/starts-with? n "xmlns")) + (re-matches dangerous-attrs-pattern (local-name k))) + (and (= "href" (local-name k)) + (string? v) + (javascript-url? v)))))) + (into {})) clean-content (when content (->> content (filter #(or (string? %) (map? %))) (map (fn [child] (if (map? child) - (sanitize-svg-element child) + (sanitize-svg-element child bindings) child))) (filter some?) vec))] @@ -97,22 +227,65 @@ (defn sanitize-svg "Sanitize SVG content by removing dangerous elements and attributes. Removes " result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "foreignObject"))) - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "foreignObject"))) - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "javascript:"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "onload"))) - (t/is (not (clojure.string/includes? result "onclick"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "javascript:"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "hola" result (svg/sanitize-svg svg)] - (t/is (clojure.string/includes? result "xml:space")) - (t/is (clojure.string/includes? result "hola"))))) + (t/is (str/includes? result "xml:space")) + (t/is (str/includes? result "hola"))))) + +;; --- SVG sanitization: escaping on serialization +;; +;; The sanitizer filters dangerous elements and attributes by name, so the +;; escaping happens when the tree is written back. A payload hidden in an +;; attribute value or in a text node must not become live markup again. + +(def ^:private clean-svg + "") + +(def ^:private evil-attribute-svg + ""] + (with-redefs [app.config/config (assoc app.config/config :media-max-file-size 1)] + (let [params {::th/type :upload-organization-logo + ::rpc/profile-id (:id profile) + :organization-id (uuid/random) + :content {:filename "big.svg" + :path path + :mtype "image/svg+xml" + :size (fs/size path)}} + out (th/multipart-management-command! params)] + (t/is (th/ex-of-type? (:error out) :restriction)) + (t/is (th/ex-of-code? (:error out) :media-max-file-size-reached)))))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 3b42685b4d..428489e9a4 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -10,10 +10,12 @@ [app.db :as db] [app.http.client :as http] [app.media :as media] + [app.media.svg :as svg] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] + [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] [mockery.core :refer [with-mocks]]) @@ -23,6 +25,14 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(def ^:private evil-svg + "An SVG that smuggles an event handler inside an attribute value." + ""))) + (t/is (= "x' onload='alert(1)" (get (:attrs parsed) :id))) + (t/is (str/includes? content "'"))))))) + +(def ^:private xhtml-child-svg + "An SVG whose root is in the SVG namespace but whose child rebinds the + default namespace to XHTML and embeds live HTML." + "") + +(t/deftest create-file-media-object-from-url-sanitizes-xhtml-child + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + body (.getBytes ^String xhtml-child-svg "UTF-8")] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 200 + :headers {"content-type" "image/svg+xml" + "content-length" (str (alength ^bytes body))} + :body (java.io.ByteArrayInputStream. ^bytes body)}}] + (let [params {::th/type :create-file-media-object-from-url + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :url "https://example.com/evil.svg"} + out (th/command! params)] + (t/is (nil? (:error out))) + + (let [{:keys [media-id]} (:result out) + storage (:app.storage/storage th/*system*) + sobject (sto/get-object storage media-id) + content (slurp (sto/get-object-data storage sobject))] + (t/is (not (str/includes? content "iframe"))) + (t/is (not (str/includes? content "srcdoc"))) + (t/is (not (str/includes? content "http-equiv")))))))) + (t/deftest media-object-from-url-command (let [prof (th/create-profile* 1) proj (th/create-project* 1 {:profile-id (:id prof) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 277228ffcd..51a1e746e1 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -15,7 +15,7 @@ [app.db :as db] [app.email.blacklist :as email.blacklist] [app.http :as http] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] @@ -25,11 +25,23 @@ [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [yetti.request])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +;; Minimal request stub, so the audit event can be built the way the backend +;; builds it: with the client ip in the event context. +(def ^:private http-request + (reify + yetti.request/IRequest + (get-header [_ header] + (case header + "x-forwarded-for" "127.0.0.44" + "x-real-ip" "127.0.0.43" + nil)))) + (t/deftest create-team-invitations (with-mocks [mock {:target 'app.email/send-template :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -479,26 +491,229 @@ (t/is (= (:id profile2) (:member-id claims)))))))) -(t/deftest get-team-invitation-token-requires-edition-permissions - (let [profile1 (th/create-profile* 1 {:is-active true}) - profile2 (th/create-profile* 2 {:is-active true}) - team (th/create-team* 1 {:profile-id (:id profile1)}) +(t/deftest get-team-invitation-token-requires-admin-permissions + ;; The token is a bearer credential: whoever holds it accepts the + ;; invitation with the role of the invitation row. Minting it takes the + ;; same permissions as the rest of the invitation commands, so an editor + ;; (the lowest role above viewer) must not get it. + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + editor (th/create-profile* 3 {:is-active true}) + admin (th/create-profile* 4 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) pool (:app.db/pool th/*system*)] (th/create-team-role* {:team-id (:id team) - :profile-id (:id profile2) + :profile-id (:id viewer) :role :viewer}) + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) (db/insert! pool :team-invitation {:team-id (:id team) :email-to "victim@example.com" :role "editor" + :created-by (:id owner) :valid-until (ct/in-future "48h")}) - (let [data {::th/type :get-team-invitation-token - ::rpc/profile-id (:id profile2) - :team-id (:id team) - :email "victim@example.com"} - out (th/command! data)] - (t/is (not (th/success? out))) - (t/is (= :not-found (-> out :error ex-data :type)))))) + (let [request (fn [profile-id] + {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile-id) + :team-id (:id team) + :email "victim@example.com"})] + (t/testing "a viewer cannot get the token" + (let [out (th/command! (request viewer))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an editor cannot get the token" + (let [out (th/command! (request editor))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an admin can get the token" + (let [out (th/command! (request admin))] + (t/is (th/success? out)) + (let [claims (tokens/decode th/*system* (-> out :result :token))] + (t/is (= :team-invitation (:iss claims))) + (t/is (= :editor (:role claims))) + (t/is (= (:id team) (:team-id claims))) + (t/is (= "victim@example.com" (:member-email claims)))))) + + (t/testing "the team owner can get the token" + (let [out (th/command! (request owner))] + (t/is (th/success? out))))))) + + +(t/deftest get-team-invitation-token-owner-invitation-requires-team-owner + ;; An admin cannot create nor promote an owner invitation, so it cannot + ;; mint the token of one either: only the team owner can. + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "new-owner@example.com" + :role "owner" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + (let [request (fn [profile-id] + {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile-id) + :team-id (:id team) + :email "new-owner@example.com"})] + (t/testing "an admin cannot get the token of an owner invitation" + (let [out (th/command! (request admin))] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))) + + (t/testing "the team owner can get the token of an owner invitation" + (let [out (th/command! (request owner))] + (t/is (th/success? out)) + (let [claims (tokens/decode th/*system* (-> out :result :token))] + (t/is (= :owner (:role claims))) + (t/is (= "new-owner@example.com" (:member-email claims))))))))) + + +(t/deftest get-team-invitation-token-emits-audit-event + ;; Minting the token is an action, not a read: the event is named as such + ;; and carries the granted role, so the extraction of the token does not go + ;; unnoticed in the audit log. + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + (let [invitation (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")})] + + (let [params {::rpc/profile-id (:id admin) + :team-id (:id team) + :email "victim@example.com"} + result (th/command-raw! (assoc params ::th/type :get-team-invitation-token)) + [mdata _] (get-in th/*system* [:app.rpc/methods :get-team-invitation-token]) + ;; The cfg of a command carries its type, the same way + ;; `resolve-methods` does it in production. + event (audit/prepare-rpc-event (assoc th/*system* ::rpc/module "main" + ::rpc/type "command") + mdata + (with-meta params {:app.http/request http-request}) + result)] + (t/is (= "create-team-invitation-token" (:name event))) + (t/is (= "command" (:type event))) + (t/is (= (:id admin) (:profile-id event))) + (t/is (= (:id team) (get-in event [:props :team-id]))) + (t/is (= "victim@example.com" (get-in event [:props :email]))) + (t/is (= :editor (get-in event [:props :role]))) + (t/is (= (:id invitation) (get-in event [:props :invitation-id]))) + (t/is (not (contains? (:props event) :token))))))) + + +(t/deftest get-team-invitations-requires-admin-permissions + ;; The pending invitations carry the email of the invited person and the + ;; role offered to them: the same data the rest of the invitation + ;; commands already require admin for. + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + editor (th/create-profile* 3 {:is-active true}) + admin (th/create-profile* 4 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + (let [request (fn [profile-id] + {::th/type :get-team-invitations + ::rpc/profile-id (:id profile-id) + :team-id (:id team)})] + (t/testing "a viewer cannot list the pending invitations" + (let [out (th/command! (request viewer))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an editor cannot list the pending invitations" + (let [out (th/command! (request editor))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an admin can list the pending invitations" + (let [out (th/command! (request admin))] + (t/is (th/success? out)) + (t/is (= 1 (count (:result out)))) + (t/is (= "victim@example.com" (:email (first (:result out))))))) + + (t/testing "the team owner can list the pending invitations" + (let [out (th/command! (request owner))] + (t/is (th/success? out)) + (t/is (= 1 (count (:result out))))))))) + + +(t/deftest invitation-permissions-deny-non-members + ;; A profile that is not a member of the team has no permissions row at + ;; all, so the check gets nil instead of a map of flags. That is also + ;; the case of an organization owner that is not a member of the team: + ;; the team permissions lookup is membership only, so they are left out + ;; of the invitation commands too. + ;; + ;; NOTE: the audit wrapper is installed when the system boots, and only + ;; when the audit log, webhooks or telemetry flags are on, none of them + ;; is on in the test environment. So there is no way to observe here + ;; whether a denied call emits an event: that is a property of the + ;; wrapper, shared by every command. + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + + (t/testing "a non member cannot get the token" + (let [out (th/command! {::th/type :get-team-invitation-token + ::rpc/profile-id (:id outsider) + :team-id (:id team) + :email "victim@example.com"})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "a non member cannot list the pending invitations" + (let [out (th/command! {::th/type :get-team-invitations + ::rpc/profile-id (:id outsider) + :team-id (:id team)})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))))) (t/deftest accept-invitation-tokens diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index 3e950a81b8..02490a3c81 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -196,6 +196,19 @@ shape-name (ctv/remove-prefix (:name shape) container-name)] (ctv/path-to-properties shape-name base-properties min-props))) +(defn- fill-blank-properties + "When every value in `props` is blank, set the last one to the first + `Value N` not contained in `used-values`, so the variant does not end up + with an empty combination." + [props used-values] + (if (and (seq props) (every? (comp str/blank? :value) props)) + (let [value (->> (iterate inc 1) + (map #(str ctv/value-prefix %)) + (remove used-values) + first)] + (assoc-in (vec props) [(dec (count props)) :value] value)) + props)) + (defn generate-make-shapes-variant [changes shapes variant-container] @@ -244,24 +257,32 @@ changes (pcb/update-shapes changes (map :id shapes) #(assoc % :variant-id variant-id - :name (:name variant-container)))] - (reduce - (fn [changes shape] - (let [component (ctcl/get-component data (:component-id shape) true)] - (if (or (zero? num-shapes) ;; do nothing if there are no shapes - (and (= variant-id (:variant-id shape)) ;; or we are only moving the shape inside its parent (it is - (not (:deleted component)))) ;; the same parent and the component isn't deleted) - changes - (let [props (create-new-properties shape total-props) - variant-name (ctv/properties-to-name props)] - (-> (pcb/update-component changes - (:component-id shape) - #(assoc % :variant-id variant-id - :variant-properties props - :name cname - :path cpath) - {:apply-changes-local-library? true}) - (pcb/update-shapes [(:id shape)] - #(assoc % :variant-name variant-name))))))) - changes - shapes))) + :name (:name variant-container))) + + ;; Property values already present in the container's variants + used-values (->> (cfv/extract-properties-values data objects variant-id) + (mapcat :value) + set)] + (first + (reduce + (fn [[changes used-values] shape] + (let [component (ctcl/get-component data (:component-id shape) true)] + (if (or (zero? num-shapes) ;; do nothing if there are no shapes + (and (= variant-id (:variant-id shape)) ;; or we are only moving the shape inside its parent (it is + (not (:deleted component)))) ;; the same parent and the component isn't deleted) + [changes used-values] + (let [props (-> (create-new-properties shape total-props) + (fill-blank-properties used-values)) + variant-name (ctv/properties-to-name props)] + [(-> (pcb/update-component changes + (:component-id shape) + #(assoc % :variant-id variant-id + :variant-properties props + :name cname + :path cpath) + {:apply-changes-local-library? true}) + (pcb/update-shapes [(:id shape)] + #(assoc % :variant-name variant-name))) + (into used-values (map :value) props)])))) + [changes used-values] + shapes)))) diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index 0323df3afb..8162cf5598 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -412,6 +412,42 @@ (t/is (contains? values "Bigred")))) +(t/deftest test-make-shapes-variant-same-name-components-get-unique-values + ;; Mirrors combine-as-variants with components that share the container + ;; name: the first main defines the container, the rest are moved into it. + (let [file (-> (thf/sample-file :file1) + (tho/add-frame :v01 {:name "Tag"}) + (tho/add-simple-component :c01 :m01 :r01 + :root-params {:parent-label :v01 :name "Tag"}) + (tho/add-simple-component :c02 :m02 :r02 + :root-params {:name "Tag"}) + (tho/add-simple-component :c03 :m03 :r03 + :root-params {:name "Tag"})) + page (thf/current-page file) + define (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (pcb/with-library-data (:data file)) + (clv/generate-define-variant-container + (thi/id :v01) (thi/id :m01) (thi/id :c01) "Tag")) + file (thf/apply-changes file define) + + page (thf/current-page file) + container (ths/get-shape file :v01) + shapes [(ths/get-shape file :m02) (ths/get-shape file :m03)] + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-make-shapes-variant shapes container) + (pcb/change-parent (:id container) shapes 0)) + file' (thf/apply-changes file changes) + values (mapv #(-> (thc/get-component file' %) :variant-properties first :value) + [:c01 :c02 :c03])] + + (t/is (= ["Value 1" "Value 2" "Value 3"] values)) + (t/is (= "Value 2" (:variant-name (ths/get-shape file' :m02)))) + (t/is (= "Value 3" (:variant-name (ths/get-shape file' :m03)))))) + (t/deftest test-duplicate-variant-container (let [;; ==== Setup diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 22dd5d9fdc..24b4ba8cb0 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -124,10 +124,15 @@ This will fetch the latest images. When you do docke #### Upgrade to 2.19 -The Admin Console is now enabled by default, so the `enable-admin-console` flag is no longer -needed and can be removed from `PENPOT_FLAGS`. The `penpot-admin-console` service and the -`PENPOT_ADMIN_CONSOLE_URI` variable are now required: if you haven't set them up yet, follow -steps 2–4 of the [2.18 upgrade](#upgrade-to-2.18). +The Admin Console is now enabled by default, so the `enable-admin-console` flag is no longer needed and can be removed from `PENPOT_FLAGS`. + +The `penpot-admin-console` service and the `PENPOT_ADMIN_CONSOLE_URI` variable are part of the standard setup and are included in the official `docker-compose.yaml`. If you [upgraded to 2.18](#upgrade-to-2.18) following the guide, there is nothing else to do. + +If you don't want to run the Admin Console, you can disable it. Penpot keeps working as usual. + +1. Add `disable-admin-console` to `PENPOT_FLAGS`. +2. Remove the `penpot-admin-console` service from your `docker-compose.yaml`, and from the `depends_on` list of `penpot-frontend`. +3. Leave the `PENPOT_ADMIN_CONSOLE_URI` variable empty. #### Upgrade to 2.18 diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 3371dcd592..ff993be7d9 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -462,6 +462,18 @@ (rx/map #(partial fetched-comments %)) (rx/catch #(rx/throw {:type :comment-error}))))))) +;; Has to fit in the limit of the backend rpc +;; (backend/src/app/rpc/commands/comments.clj). +(def ^:private mark-all-threads-batch-size 500) + +(defn mark-all-threads-batches + "Splits the thread ids into the batches that the backend accepts on a + single call." + [thread-ids] + (->> thread-ids + (partition-all mark-all-threads-batch-size) + (mapv vec))) + (defn mark-all-threads-as-read "Mark all threads as read" [team-id] @@ -470,11 +482,23 @@ (-data [_] {}) ptk/WatchEvent (watch [_ state _] - (let [threads (-> state :comment-threads vals)] + (let [threads (-> state :comment-threads vals) + batches (mark-all-threads-batches (mapv :id threads)) + refresh-event (retrieve-unread-comment-threads team-id)] (rx/concat - (->> (rp/cmd! :mark-all-threads-as-read {:threads (mapv :id threads)}) - (rx/map #(retrieve-unread-comment-threads team-id)) - (rx/catch #(rx/throw {:type :comment-error}))) + (->> (rx/from batches) + (rx/mapcat (fn [batch] + (rp/cmd! :mark-all-threads-as-read {:threads batch}))) + (rx/reduce (fn [_ _] nil) nil) + (rx/map (fn [_] refresh-event)) + ;; Each batch commits on the server on its own, so a failure in + ;; a later batch leaves the previous ones already marked as + ;; read. The unread list is re-read anyway and the error is + ;; propagated afterwards, so the success toast below stays + ;; limited to a full success. + (rx/catch (fn [_] + (rx/concat (rx/of refresh-event) + (rx/throw {:type :comment-error}))))) (rx/of (ntf/show {:level :info :type :toast :content (tr "dashboard.mark-all-as-read.success") diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index e8afbfc994..fe2b796ec8 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -259,7 +259,20 @@ (watch [_ state _] (let [team-id (:current-team-id state)] (->> (rp/cmd! :get-team-invitations {:team-id team-id}) - (rx/map (partial invitations-fetched team-id))))))) + (rx/map (partial invitations-fetched team-id)) + (rx/catch (fn [cause] + (let [{:keys [type code]} (ex-data cause)] + (if (and (= :validation type) + (= :insufficient-permissions code)) + ;; The pending invitations are an admin only + ;; list, so a denied refetch means the cached + ;; copy is no longer valid: drop it instead of + ;; leaving stale emails around. + (do + (log/warn :hint "fetch-invitations: no permission, clearing" + :team-id (str team-id)) + (rx/of (invitations-fetched team-id []))) + (rx/throw cause)))))))))) (defn- team-initialized [team-id] diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 46730ee112..49eb435f34 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -697,10 +697,13 @@ (transform-in-variant-stream state (first ids) variant-id delta prefix add-wrapper? false false current-page) - (rx/of (dwsh/relocate-shapes (into #{} (rest ids)) variant-id 0) - ;; relocate-shapes inserts the shapes in layer-tree - ;; order, so restore the intended order afterwards - (order-variant-children variant-id ids) + ;; One relocation per main, in `ids` order, so the + ;; generated property values follow the combine order + (rx/from (map #(dwsh/relocate-shapes #{%} variant-id 0) (rest ids))) + + ;; Each relocation inserts at index 0, so restore the + ;; intended order afterwards + (rx/of (order-variant-children variant-id ids) (dwsh/update-shapes ids #(-> % (assoc :constraints-h :left) (assoc :constraints-v :top) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs index 2124f5d673..521d98076c 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs @@ -180,10 +180,12 @@ :data-testid "team-members"} (tr "labels.members")] - [:> dropdown-menu-item* {:on-click on-invitations-click - :class (stl/css :options-item) - :data-testid "team-invitations"} - (tr "labels.invitations")] + ;; The pending invitations are an admin only list. + (when (:is-admin permissions) + [:> dropdown-menu-item* {:on-click on-invitations-click + :class (stl/css :options-item) + :data-testid "team-invitations"} + (tr "labels.invitations")]) (when (contains? cf/flags :webhooks) [:> dropdown-menu-item* {:on-click on-webhooks-click diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 081b204af2..b123c31772 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -70,6 +70,7 @@ invitations-section? (= section :dashboard-team-invitations) webhooks-section? (= section :dashboard-team-webhooks) permissions (:permissions team) + is-admin? (boolean (:is-admin permissions)) can-invite? (dnt/can-send-invitations? {:organization (:organization team) :profile-id (:id profile) @@ -110,10 +111,11 @@ [:a {:class (stl/css-case :dashboard-header-link true :active members-section?) :on-click on-nav-members} (tr "labels.members")]] - [:li - [:a {:class (stl/css-case :dashboard-header-link true - :active invitations-section?) - :on-click on-nav-invitations} (tr "labels.invitations")]] + (when is-admin? + [:li + [:a {:class (stl/css-case :dashboard-header-link true + :active invitations-section?) + :on-click on-nav-invitations} (tr "labels.invitations")]]) (when (contains? cfg/flags :webhooks) [:li [:a {:class (stl/css-case :dashboard-header-link true @@ -124,7 +126,9 @@ :active settings-section?) :on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} - (when (and (or invitations-section? members-section?) (not-empty invitations)) + (when (and is-admin? + (or invitations-section? members-section?) + (not-empty invitations)) (let [invite-button (mf/html [:> button* {:class (stl/css :invite-button) :variant "secondary" @@ -661,7 +665,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc invitation-role-selector* - [{:keys [can-invite role status on-change]}] + [{:keys [role status on-change]}] (let [show? (mf/use-state false) label (cond (= role :owner) (tr "labels.owner") @@ -682,7 +686,7 @@ (on-change role event))))] [:* - (if (and can-invite (= status :pending)) + (if (= status :pending) [:button {:class (stl/css :rol-selector :has-priv) :type "button" :aria-haspopup "listbox" @@ -709,9 +713,16 @@ :on-click on-change'} (tr "labels.viewer")]]]])) +(defn invitation-link-copyable? + "The link of an owner invitation can only be copied by the team owner: an + admin cannot create nor promote an owner invitation, so the backend does + not mint its token for them." + [is-owner? role] + (or (not= :owner role) is-owner?)) + (mf/defc invitation-actions* {::mf/private true} - [{:keys [invitation team-id]}] + [{:keys [invitation team-id is-owner?]}] (let [email (:email invitation) copied* (mf/use-state false) copied? (deref copied*) @@ -757,15 +768,16 @@ (-> (dtm/copy-invitation-link params) (with-meta {::ev/origin :team}))))))] - [:> icon-button* {:variant "ghost" - :aria-label (tr "labels.copy-invitation-link") - :on-click on-copy - :icon (if copied? "tick" "clipboard")}])) + (when (invitation-link-copyable? is-owner? (:role invitation)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.copy-invitation-link") + :on-click on-copy + :icon (if copied? "tick" "clipboard")}]))) (mf/defc invitation-row* {::mf/wrap [mf/memo] ::mf/private true} - [{:keys [invitation can-invite team-id selected on-select-change]}] + [{:keys [invitation team-id selected on-select-change is-owner?]}] (let [expired? (:expired invitation) email (:email invitation) @@ -783,12 +795,11 @@ on-change (mf/use-fn - (mf/deps can-invite on-select-change) + (mf/deps on-select-change) (fn [event] - (when can-invite - (let [email (-> (dom/get-current-target event) - (dom/get-data "attr"))] - (on-select-change email))))) + (let [email (-> (dom/get-current-target event) + (dom/get-data "attr"))] + (on-select-change email)))) on-change-role (mf/use-fn @@ -800,26 +811,23 @@ [:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-field :field-email)} - (if can-invite - [:div {:class (stl/css :input-wrapper)} - [:label - [:span {:class (stl/css-case :input-checkbox true - :global/checked (is-selected? email))} - deprecated-icon/status-tick] + [:div {:class (stl/css :input-wrapper)} + [:label + [:span {:class (stl/css-case :input-checkbox true + :global/checked (is-selected? email))} + deprecated-icon/status-tick] - [:input {:type "checkbox" - :id (dm/str "email-" email) - :data-attr email - :value email - :checked (is-selected? email) - :on-change on-change}] - email]] - [:div email])] + [:input {:type "checkbox" + :id (dm/str "email-" email) + :data-attr email + :value email + :checked (is-selected? email) + :on-change on-change}] + email]]] [:div {:class (stl/css :table-field :field-roles)} [:> invitation-role-selector* - {:can-invite can-invite - :role role + {:role role :status status :on-change on-change-role}]] @@ -827,14 +835,14 @@ [:& badge-notification {:type type :content badge-content}]] [:div {:class (stl/css :table-field :field-actions)} - (when ^boolean can-invite - [:> invitation-actions* - {:invitation invitation - :team-id team-id}])]])) + [:> invitation-actions* + {:invitation invitation + :team-id team-id + :is-owner? is-owner?}]]])) (mf/defc empty-invitation-table* {::mf/private true} - [{:keys [can-invite team]}] + [{:keys [team]}] (let [route (mf/deref refs/route) invite-email (-> route :query-params :invite-email) @@ -846,15 +854,12 @@ :invite-email invite-email}))))] [:div {:class (stl/css :empty-invitations)} [:div (tr "labels.no-invitations")] - (if ^boolean can-invite - [:* - [:div (tr "labels.no-invitations-gather-people")] - [:> button* {:variant "primary" - :class (stl/css :btn-empty-invitations) - :on-click on-invite-member - :data-testid "invite-member"} - (tr "dashboard.invite-profile")]] - [:div {:class (stl/css :no-permission-text)} (tr "dashboard.invitations.no-permission")])])) + [:div (tr "labels.no-invitations-gather-people")] + [:> button* {:variant "primary" + :class (stl/css :btn-empty-invitations) + :on-click on-invite-member + :data-testid "invite-member"} + (tr "dashboard.invite-profile")]])) (mf/defc invitation-modal {::mf/register modal/components @@ -1059,6 +1064,17 @@ :on-click on-confirm'} accept]]]]])) +(defn invitations-section-content + "What the invitations section shows: the list of the pending invitations + for the team admins, or just the permission notice. The pending invitations + are an admin only list, and `:is-admin` already includes the team owner. + The organization invite policy does not gate the list, only the invite + actions." + [permissions] + (if (:is-admin permissions) + :list + :no-permission)) + (mf/defc invitation-section* {::mf/private true} [{:keys [team profile]}] @@ -1072,6 +1088,8 @@ :profile-id (:id profile) :team-permissions permissions}) + is-owner? (boolean (:is-owner permissions)) + selected (mf/use-state #{}) ;; Sort state: {:field :status/:role, :direction :asc/:desc} @@ -1082,12 +1100,11 @@ on-select-change (mf/use-fn - (mf/deps can-invite? selected) + (mf/deps selected) (fn [email] - (when can-invite? - (if (contains? @selected email) - (swap! selected disj email) - (swap! selected conj email))))) + (if (contains? @selected email) + (swap! selected disj email) + (swap! selected conj email)))) on-confirm-delete (mf/use-fn @@ -1197,57 +1214,63 @@ (reset! sort-state {:field nil :direction :asc})) [:div {:class (stl/css :invitations)} - (when (and (not can-invite?) - (seq @invitations)) + ;; The pending invitations are an admin only list: a member that is not + ;; a team admin does not get them, so there is nothing to show but the + ;; permission notice. + (case (invitations-section-content permissions) + :no-permission [:div {:class (stl/css :empty-invitations)} [:div {:class (stl/css :no-permission-text)} - (tr "dashboard.invitations.no-permission")]]) - (when (and can-invite? - (> (count @selected) 0)) + (tr "dashboard.invitations.no-permission")]] + [:* - [:div {:class (stl/css :invitations-actions)} - [:div - (tr "team.invitations-selected" (i18n/c (count @selected)))] - [:div - [:> button* {:variant "secondary" - :type "button" - :on-click on-resend} - (tr "labels.resend-invitation")]] - [:> icon-button* {:on-click on-delete - :variant "destructive" - :aria-label (tr "labels.delete-invitation") - :icon "delete"}]]]) - [:div {:class (stl/css :table-header)} - [:div {:class (stl/css :title-field-name)} (tr "labels.invitations")] - [:div {:class (stl/css :title-field-role)} (tr "labels.role") - [:> icon-button* {:variant "action" - :class (stl/css-case :sort-active (= (:field @sort-state) :role) - :sort-inactive (not= (:field @sort-state) :role)) - :aria-label (tr "dashboard.order-invitations-by-role") - :icon (if (= (:field @sort-state) :role) - (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") - "arrow-down") - :on-click on-order-by-role}]] - [:div {:class (stl/css :title-field-status)} (tr "labels.status") - [:> icon-button* {:variant "action" - :class (stl/css-case :sort-active (= (:field @sort-state) :status) - :sort-inactive (not= (:field @sort-state) :status)) - :aria-label (tr "dashboard.order-invitations-by-status") - :icon (if (= (:field @sort-state) :status) - (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") - "arrow-down") - :on-click on-order-by-status}]]] - (if (empty? @invitations) - [:> empty-invitation-table* {:can-invite can-invite? :team team}] - [:div {:class (stl/css :table-rows)} - (for [invitation @invitations] - [:> invitation-row* - {:key (:email invitation) - :invitation invitation - :can-invite can-invite? - :team-id team-id - :selected selected - :on-select-change on-select-change}])])])) + (when (> (count @selected) 0) + [:div {:class (stl/css :invitations-actions)} + [:div + (tr "team.invitations-selected" (i18n/c (count @selected)))] + ;; Resending goes through the invite command, so it needs the + ;; organization invite policy; deleting only needs team admin. + (when can-invite? + [:div + [:> button* {:variant "secondary" + :type "button" + :on-click on-resend} + (tr "labels.resend-invitation")]]) + [:> icon-button* {:on-click on-delete + :variant "destructive" + :aria-label (tr "labels.delete-invitation") + :icon "delete"}]]) + [:div {:class (stl/css :table-header)} + [:div {:class (stl/css :title-field-name)} (tr "labels.invitations")] + [:div {:class (stl/css :title-field-role)} (tr "labels.role") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :role) + :sort-inactive (not= (:field @sort-state) :role)) + :aria-label (tr "dashboard.order-invitations-by-role") + :icon (if (= (:field @sort-state) :role) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-role}]] + [:div {:class (stl/css :title-field-status)} (tr "labels.status") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :status) + :sort-inactive (not= (:field @sort-state) :status)) + :aria-label (tr "dashboard.order-invitations-by-status") + :icon (if (= (:field @sort-state) :status) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-status}]]] + (if (empty? @invitations) + [:> empty-invitation-table* {:team team}] + [:div {:class (stl/css :table-rows)} + (for [invitation @invitations] + [:> invitation-row* + {:key (:email invitation) + :invitation invitation + :is-owner? is-owner? + :team-id team-id + :selected selected + :on-select-change on-select-change}])])])])) (mf/defc team-invitations-page* [{:keys [team profile]}] diff --git a/frontend/test/frontend_tests/data/comments_batches_test.cljs b/frontend/test/frontend_tests/data/comments_batches_test.cljs new file mode 100644 index 0000000000..7bab3fa964 --- /dev/null +++ b/frontend/test/frontend_tests/data/comments_batches_test.cljs @@ -0,0 +1,95 @@ +;; 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/. + +(ns frontend-tests.data.comments-batches-test + (:require + [app.common.uuid :as uuid] + [app.main.data.comments :as dcmt] + [app.main.data.notifications :as ntf] + [app.main.repo :as rp] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- comment-threads-state + [n] + {:comment-threads (into {} (map (fn [_] [(uuid/next) {:id (uuid/next)}]) (range n)))}) + +(t/deftest mark-all-threads-batches-splits-the-ids + (t/is (= [] (dcmt/mark-all-threads-batches []))) + (t/is (= [[1 2 3]] (dcmt/mark-all-threads-batches [1 2 3]))) + (t/is (= [500 1] (mapv count (dcmt/mark-all-threads-batches (range 501)))))) + +(t/deftest ^:async mark-all-threads-as-read-sends-the-ids-in-batches + (let [calls (atom []) + state (comment-threads-state 1200) + event (dcmt/mark-all-threads-as-read (uuid/next))] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (rx/of nil)))} + (await (async/observe (ptk/watch event state nil))))) + + (t/is (= 3 (count @calls))) + (t/is (every? #(= :mark-all-threads-as-read (first %)) @calls)) + (t/is (= [500 500 200] (mapv (comp count :threads second) @calls))))) + +(t/deftest ^:async mark-all-threads-as-read-sends-a-single-batch + (let [calls (atom []) + state (comment-threads-state 10) + event (dcmt/mark-all-threads-as-read (uuid/next))] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (rx/of nil)))} + (await (async/observe (ptk/watch event state nil))))) + + (t/is (= 1 (count @calls))) + (t/is (= 10 (count (:threads (second (first @calls)))))))) + +(t/deftest ^:async mark-all-threads-as-read-refreshes-the-unread-list-after-a-batch-fails + (let [calls (atom []) + state (comment-threads-state 1200) + event (dcmt/mark-all-threads-as-read (uuid/next)) + seen (atom []) + errors (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (if (= 2 (count @calls)) + (rx/throw (ex-info "boom" {})) + (rx/of nil))))} + (await (async/observe (ptk/watch event state nil) + :on-next #(swap! seen conj (ptk/type %)) + :on-error #(swap! errors conj %))))) + + (t/is (= 2 (count @calls)) + "the pipeline stops at the batch that fails") + (t/is (= [::dcmt/retrieve-unread-comment-threads] @seen) + "the unread list is re-read and no success toast is emitted") + (t/is (= [{:type :comment-error}] @errors)))) + +(t/deftest ^:async mark-all-threads-as-read-refreshes-and-shows-the-success-toast + (let [state (comment-threads-state 600) + event (dcmt/mark-all-threads-as-read (uuid/next)) + seen (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of nil)))} + (await (async/observe (ptk/watch event state nil) + :on-next #(swap! seen conj (ptk/type %)))))) + + (t/is (= [::dcmt/retrieve-unread-comment-threads ::ntf/show] @seen)))) diff --git a/frontend/test/frontend_tests/data/team_test.cljs b/frontend/test/frontend_tests/data/team_test.cljs new file mode 100644 index 0000000000..9bf84efc79 --- /dev/null +++ b/frontend/test/frontend_tests/data/team_test.cljs @@ -0,0 +1,81 @@ +;; 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/. + +(ns frontend-tests.data.team-test + (:require + [app.common.uuid :as uuid] + [app.main.data.team :as dtm] + [app.main.repo :as rp] + [app.main.ui.dashboard.team :as dtui] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- fetch-invitations-events + "Runs the watch of `fetch-invitations` with `rp/cmd!` answering with + `response` and resolves to the team id and the events it emits." + [response] + (let [team-id (uuid/next) + state {:current-team-id team-id} + emitted (atom [])] + (-> (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_cmd _params] response))} + (await (async/observe (ptk/watch (dtm/fetch-invitations) state nil) + :on-next #(swap! emitted conj %)))) + (.then (fn [_] {:team-id team-id :events @emitted}))))) + +(t/deftest ^:async fetch-invitations-clears-the-list-on-permission-error + ;; The pending invitations are an admin only list: a member that cannot + ;; invite gets an insufficient permissions error. That must not become an + ;; error toast, and the cached list must be dropped so the stale emails do + ;; not stay around. + (let [{:keys [team-id events]} + (await (fetch-invitations-events + (rx/throw (ex-info "insufficient permissions" + {:type :validation + :code :insufficient-permissions})))) + event (first events) + state (ptk/update event {:teams {team-id {:id team-id + :invitations [{:email "stale@example.com" + :role :editor}]}}})] + (t/is (= 1 (count events))) + (t/is (= ::dtm/invitations-fetched (ptk/type event))) + (t/is (= [] (get-in state [:teams team-id :invitations]))))) + +(t/deftest ^:async fetch-invitations-propagates-other-errors + ;; Only the permission failure is swallowed: any other error is a real + ;; problem and must reach the error reporting. + (let [outcome (await (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_cmd _params] + (rx/throw (ex-info "boom" {:type :server}))))} + (try + (await (async/observe (ptk/watch (dtm/fetch-invitations) + {:current-team-id (uuid/next)} + nil))) + :resolved + (catch js/Error _ :rejected))))] + (t/is (= :rejected outcome)))) + +(t/deftest ^:async fetch-invitations-emits-the-update-event + (let [{:keys [events]} (await (fetch-invitations-events + (rx/of [{:email "invited@example.com" :role :editor}])))] + (t/is (= 1 (count events))) + (t/is (= ::dtm/invitations-fetched (ptk/type (first events)))))) + +(t/deftest invitations-section-shows-the-list-only-to-the-team-admins + ;; The list is admin only, and the backend normalizes `:is-admin` to + ;; include the team owner. The organization invite policy does not gate it. + (t/is (= :list (dtui/invitations-section-content {:is-owner false :is-admin true}))) + (t/is (= :list (dtui/invitations-section-content {:is-owner true :is-admin true}))) + (t/is (= :no-permission (dtui/invitations-section-content {:is-owner false :is-admin false})))) + +(t/deftest owner-invitation-link-is-copyable-only-by-the-team-owner + ;; An admin cannot create nor promote an owner invitation, so the backend + ;; does not mint its token for them: the action is not offered either. + (t/is (true? (dtui/invitation-link-copyable? true :owner))) + (t/is (false? (dtui/invitation-link-copyable? false :owner))) + (t/is (true? (dtui/invitation-link-copyable? false :editor))) + (t/is (true? (dtui/invitation-link-copyable? false :viewer)))) diff --git a/frontend/test/frontend_tests/data/workspace_variants_test.cljs b/frontend/test/frontend_tests/data/workspace_variants_test.cljs index 20c2fa7acf..d596add6c2 100644 --- a/frontend/test/frontend_tests/data/workspace_variants_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_variants_test.cljs @@ -190,6 +190,16 @@ (tho/add-simple-component :c02 :m02 :r02 :root-params {:name "UI / Beta" :x 150 :y 0 :width 100 :height 80}))) +(defn- three-same-name-mains-file + [] + (-> (cthf/sample-file :file1) + (tho/add-simple-component :c01 :m01 :r01 + :root-params {:name "Tag" :x 0 :y 0 :width 100 :height 80}) + (tho/add-simple-component :c02 :m02 :r02 + :root-params {:name "Tag" :x 150 :y 0 :width 100 :height 80}) + (tho/add-simple-component :c03 :m03 :r03 + :root-params {:name "Tag" :x 300 :y 0 :width 100 :height 80}))) + (defn- path-named-main-file [] (-> (cthf/sample-file :file1) @@ -524,6 +534,49 @@ (t/is (= variant-id (:parent-id m01') (:parent-id m02'))))) after-commits-idle)))) +(t/deftest combine-as-variants-same-name-gives-unique-values + (t/async + done + (let [file (three-same-name-mains-file) + ids [(cthi/id :m01) (cthi/id :m02) (cthi/id :m03)] + variant-id (uuid/next) + store (ths/setup-store file)] + (ths/run-store + store + done + [(dwv/combine-as-variants ids {:variant-id variant-id :trigger "test"})] + (fn [state] + (let [file' (ths/get-file-from-state state) + values (mapv #(-> (cthc/get-component file' %) :variant-properties first :value) + [:c01 :c02 :c03])] + (cthf/validate-file! file') + (t/is (= ["Value 1" "Value 2" "Value 3"] values)))) + after-commits-idle)))) + +(t/deftest combine-as-variants-same-name-values-follow-combine-order + ;; The combine order differs from the layer order of the mains + (t/async + done + (let [file (three-same-name-mains-file) + ids [(cthi/id :m03) (cthi/id :m01) (cthi/id :m02)] + variant-id (uuid/next) + store (ths/setup-store file)] + (ths/run-store + store + done + [(dwv/combine-as-variants ids {:variant-id variant-id :trigger "test"})] + (fn [state] + (let [file' (ths/get-file-from-state state) + objects (page-objects file') + values (mapv #(-> (cthc/get-component file' %) :variant-properties first :value) + [:c03 :c01 :c02]) + ordered (->> (cfv/find-variant-components (:data file') objects variant-id) + (mapv #(-> % :variant-properties first :value)))] + (cthf/validate-file! file') + (t/is (= ["Value 1" "Value 2" "Value 3"] values)) + (t/is (= ["Value 1" "Value 2" "Value 3"] ordered)))) + after-commits-idle)))) + (t/deftest combine-as-variants-from-another-page-validates-after-every-commit (t/async done diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 26adc6eca4..8443e2c218 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -10,6 +10,7 @@ [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.copy-as-svg-test] [frontend-tests.data.auth-test] + [frontend-tests.data.comments-batches-test] [frontend-tests.data.comments-filters-test] [frontend-tests.data.dashboard-test] [frontend-tests.data.event-test] @@ -30,6 +31,7 @@ [frontend-tests.data.repo-test] [frontend-tests.data.store-test] [frontend-tests.data.svg-upload-test] + [frontend-tests.data.team-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] @@ -208,6 +210,7 @@ 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.auth-test + 'frontend-tests.data.comments-batches-test 'frontend-tests.data.comments-filters-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.event-test @@ -232,6 +235,7 @@ 'frontend-tests.worker.messages-test 'frontend-tests.worker.trim-test 'frontend-tests.data.svg-upload-test + 'frontend-tests.data.team-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test diff --git a/mcp/package.json b/mcp/package.json index 3c2601a59e..977047cf57 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.19.0", + "version": "2.19.1", "description": "MCP server for Penpot integration", "license": "MPL-2.0", "bin": { diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 6725c941e5..7d33e479fe 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -13,7 +13,7 @@ "clean": "rm -rf dist/" }, "dependencies": { - "@penpot/plugin-styles": "1.5.0", + "@penpot/plugin-styles": "1.6.0", "@penpot/plugin-types": "link:../../../plugins/libs/plugin-types" }, "devDependencies": { diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 69715ca183..ecb78c628d 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -128,7 +128,8 @@ Penpot: Example ``` - const size = penpot.ui.size;console.log(size); + const size = penpot.ui.size; + console.log(size); ``` * resize: (width: number, height: number) => void @@ -186,7 +187,8 @@ Penpot: Example ``` - const rootShape = context.root;console.log(rootShape); + const rootShape = context.root; + console.log(rootShape); ``` currentFile: |- ``` @@ -201,7 +203,8 @@ Penpot: Example ``` - const fileData = context.currentFile;console.log(fileData); + const fileData = context.currentFile; + console.log(fileData); ``` currentPage: |- ``` @@ -212,7 +215,8 @@ Penpot: Example ``` - const currentPage = context.currentPage;console.log(currentPage); + const currentPage = context.currentPage; + console.log(currentPage); ``` viewport: |- ``` @@ -223,7 +227,8 @@ Penpot: Example ``` - const viewportSettings = context.viewport;console.log(viewportSettings); + const viewportSettings = context.viewport; + console.log(viewportSettings); ``` flags: |- ``` @@ -240,7 +245,8 @@ Penpot: Example ``` - const historyContext = context.history;console.log(historyContext); + const historyContext = context.history; + console.log(historyContext); ``` library: |- ``` @@ -251,7 +257,8 @@ Penpot: Example ``` - const libraryContext = context.library;console.log(libraryContext); + const libraryContext = context.library; + console.log(libraryContext); ``` fonts: |- ``` @@ -262,7 +269,8 @@ Penpot: Example ``` - const fontsContext = context.fonts;console.log(fontsContext); + const fontsContext = context.fonts; + console.log(fontsContext); ``` currentUser: |- ``` @@ -273,7 +281,8 @@ Penpot: Example ``` - const currentUser = context.currentUser;console.log(currentUser); + const currentUser = context.currentUser; + console.log(currentUser); ``` activeUsers: |- ``` @@ -284,7 +293,8 @@ Penpot: Example ``` - const activeUsers = context.activeUsers;console.log(activeUsers); + const activeUsers = context.activeUsers; + console.log(activeUsers); ``` theme: |- ``` @@ -295,7 +305,8 @@ Penpot: Example ``` - const currentTheme = context.theme;console.log(currentTheme); + const currentTheme = context.theme; + console.log(currentTheme); ``` localStorage: |- ``` @@ -312,7 +323,8 @@ Penpot: Example ``` - const selectedShapes = context.selection;console.log(selectedShapes); + const selectedShapes = context.selection; + console.log(selectedShapes); ``` Methods: on: |- @@ -334,7 +346,8 @@ Penpot: that will be observed. For example: ``` - // Observe the current selected shapepenpot.on('shapechange', (shape) => console.log(shape.name), { shapeId: penpot.selection[0].id }); + // Observe the current selected shape + penpot.on('shapechange', (shape) => console.log(shape.name), { shapeId: penpot.selection[0].id }); ``` * selectionchange: event emitted when the current selection changes. The callback will receive the list of ids for the new selection @@ -384,7 +397,8 @@ Penpot: Example ``` - const listenerId = penpot.on('contentsave', () => console.log("Changed"));penpot.off(listenerId); + const listenerId = penpot.on('contentsave', () => console.log("Changed")); + penpot.off(listenerId); ``` shapesColors: |- ``` @@ -403,7 +417,8 @@ Penpot: Example ``` - const colors = context.shapesColors(shapes);console.log(colors); + const colors = context.shapesColors(shapes); + console.log(colors); ``` replaceColor: |- ``` @@ -446,7 +461,14 @@ Penpot: Example ``` - const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');console.log(imageData);// to insert the image in a shape we can doconst board = penpot.createBoard();const shape = penpot.createRectangle();board.appendChild(shape);shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + console.log(imageData); + + // to insert the image in a shape we can do + const board = penpot.createBoard(); + const shape = penpot.createRectangle(); + board.appendChild(shape); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; ``` uploadMediaData: |- ``` @@ -475,7 +497,8 @@ Penpot: Example ``` - const imageData = await context.uploadMediaData('example', imageData, 'image/jpeg');console.log(imageData); + const imageData = await context.uploadMediaData('example', imageData, 'image/jpeg'); + console.log(imageData); ``` group: |- ``` @@ -496,7 +519,8 @@ Penpot: Example ``` - const penpotShapesArray = penpot.selection;penpot.group(penpotShapesArray); + const penpotShapesArray = penpot.selection; + penpot.group(penpotShapesArray); ``` ungroup: |- ``` @@ -518,7 +542,11 @@ Penpot: Example ``` - const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and that the selected shape is a groupif (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.ungroup(penpotShapesArray[0]);} + const penpotShapesArray = penpot.selection; + // We need to make sure that something is selected, and that the selected shape is a group + if (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { + penpot.ungroup(penpotShapesArray[0]); + } ``` createRectangle: |- ``` @@ -531,7 +559,48 @@ Penpot: Example ``` - const shape = penpot.createRectangle();// just change the values like thisshape.name = "Example rectangle";// for solid colorshape.fills = [{ fillColor: "#7EFFF5" }];// for linear gradient colorshape.fills = [{ fillColorGradient: { "type": "linear", "startX": 0.5, "startY": 0, "endX": 0.5, "endY": 1, "width": 1, "stops": [ { "color": "#003ae9", "opacity": 1, "offset": 0 }, { "color": "#003ae9", "opacity": 0, "offset": 1 } ] }}];// for a image fillconst imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');shape.fills = [{ fillOpacity: 1, fillImage: imageData }];shape.borderRadius = 8;shape.strokes = [ { strokeColor: "#2e3434", strokeStyle: "solid", strokeWidth: 2, strokeAlignment: "center", },]; + const shape = penpot.createRectangle(); + // just change the values like this + shape.name = "Example rectangle"; + + // for solid color + shape.fills = [{ fillColor: "#7EFFF5" }]; + // for linear gradient color + shape.fills = [{ + fillColorGradient: { + "type": "linear", + "startX": 0.5, + "startY": 0, + "endX": 0.5, + "endY": 1, + "width": 1, + "stops": [ + { + "color": "#003ae9", + "opacity": 1, + "offset": 0 + }, + { + "color": "#003ae9", + "opacity": 0, + "offset": 1 + } + ] + } + }]; + // for a image fill + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + + shape.borderRadius = 8; + shape.strokes = [ + { + strokeColor: "#2e3434", + strokeStyle: "solid", + strokeWidth: 2, + strokeAlignment: "center", + }, + ]; ``` createBoard: |- ``` @@ -546,7 +615,16 @@ Penpot: Example ``` - const board = penpot.createBoard();// to add grid layoutboard.addGridLayout();// to add flex layoutboard.addFlexLayout();// to create a shape inside the boardconst shape = penpot.createRectangle();board.appendChild(shape); + const board = penpot.createBoard(); + + // to add grid layout + board.addGridLayout(); + // to add flex layout + board.addFlexLayout(); + + // to create a shape inside the board + const shape = penpot.createRectangle(); + board.appendChild(shape); ``` createEllipse: |- ``` @@ -559,7 +637,47 @@ Penpot: Example ``` - const shape = penpot.createEllipse();// just change the values like thisshape.name = "Example ellipse";// for solid colorshape.fills = [{ fillColor: "#7EFFF5" }];// for linear gradient colorshape.fills = [{ fillColorGradient: { "type": "linear", "startX": 0.5, "startY": 0, "endX": 0.5, "endY": 1, "width": 1, "stops": [ { "color": "#003ae9", "opacity": 1, "offset": 0 }, { "color": "#003ae9", "opacity": 0, "offset": 1 } ] }}];// for an image fillconst imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');shape.fills = [{ fillOpacity: 1, fillImage: imageData }];shape.strokes = [ { strokeColor: "#2e3434", strokeStyle: "solid", strokeWidth: 2, strokeAlignment: "center", },]; + const shape = penpot.createEllipse(); + // just change the values like this + shape.name = "Example ellipse"; + + // for solid color + shape.fills = [{ fillColor: "#7EFFF5" }]; + // for linear gradient color + shape.fills = [{ + fillColorGradient: { + "type": "linear", + "startX": 0.5, + "startY": 0, + "endX": 0.5, + "endY": 1, + "width": 1, + "stops": [ + { + "color": "#003ae9", + "opacity": 1, + "offset": 0 + }, + { + "color": "#003ae9", + "opacity": 0, + "offset": 1 + } + ] + } + }]; + // for an image fill + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + + shape.strokes = [ + { + strokeColor: "#2e3434", + strokeStyle: "solid", + strokeWidth: 2, + strokeAlignment: "center", + }, + ]; ``` createPath: |- ``` @@ -572,7 +690,11 @@ Penpot: Example ``` - const path = penpot.createPath();path.name = "My path";// for solid colorpath.fills = [{ fillColor: "#7EFFF5" }]; + const path = penpot.createPath(); + path.name = "My path"; + + // for solid color + path.fills = [{ fillColor: "#7EFFF5" }]; ``` createBoolean: |- ``` @@ -661,7 +783,16 @@ Penpot: Example ``` - const board = penpot.createBoard();let text;text = penpot.createText();// just change the values like thistext.growType = 'auto-height';text.fontFamily = 'Work Sans';text.fontSize = '12';text.fills = [{fillColor: '#9f05ff', fillOpacity: 1}];text.strokes = [{strokeOpacity: 1, strokeStyle: 'solid', strokeWidth: 2, strokeColor: '#deabff', strokeAlignment: 'outer'}];board.appendChild(text); + const board = penpot.createBoard(); + let text; + text = penpot.createText(); + // just change the values like this + text.growType = 'auto-height'; + text.fontFamily = 'Work Sans'; + text.fontSize = '12'; + text.fills = [{fillColor: '#9f05ff', fillOpacity: 1}]; + text.strokes = [{strokeOpacity: 1, strokeStyle: 'solid', strokeWidth: 2, strokeColor: '#deabff', strokeAlignment: 'outer'}]; + board.appendChild(text); ``` generateMarkup: |- ``` @@ -683,7 +814,8 @@ Penpot: Example ``` - const markup = context.generateMarkup(shapes, { type: 'html' });console.log(markup); + const markup = context.generateMarkup(shapes, { type: 'html' }); + console.log(markup); ``` generateStyle: |- ``` @@ -713,7 +845,8 @@ Penpot: Example ``` - const styles = context.generateStyle(shapes, { type: 'css' });console.log(styles); + const styles = context.generateStyle(shapes, { type: 'css' }); + console.log(styles); ``` generateFontFaces: |- ``` @@ -731,7 +864,8 @@ Penpot: Example ``` - const fontfaces = context.generateFontFaces(penpot.selection);console.log(fontfaces); + const fontfaces = context.generateFontFaces(penpot.selection); + console.log(fontfaces); ``` openViewer: |- ``` @@ -756,7 +890,12 @@ Penpot: Example ``` - const page = penpot.createPage();page.name = 'New Page';await penpot.openPage(page); // make the new page active firstconst board = penpot.createBoard();board.resize(375, 812);page.root.appendChild(board); + const page = penpot.createPage(); + page.name = 'New Page'; + await penpot.openPage(page); // make the new page active first + const board = penpot.createBoard(); + board.resize(375, 812); + page.root.appendChild(board); ``` openPage: |- ``` @@ -939,7 +1078,8 @@ ActiveUser: Example ``` - const userPosition = activeUser.position;console.log(userPosition); + const userPosition = activeUser.position; + console.log(userPosition); ``` zoom: |- ``` @@ -950,7 +1090,8 @@ ActiveUser: Example ``` - const userZoom = activeUser.zoom;console.log(userZoom); + const userZoom = activeUser.zoom; + console.log(userZoom); ``` id: |- ``` @@ -961,7 +1102,8 @@ ActiveUser: Example ``` - const userId = user.id;console.log(userId); + const userId = user.id; + console.log(userId); ``` name: |- ``` @@ -972,7 +1114,8 @@ ActiveUser: Example ``` - const userName = user.name;console.log(userName); + const userName = user.name; + console.log(userName); ``` avatarUrl: |- ``` @@ -983,7 +1126,8 @@ ActiveUser: Example ``` - const avatarUrl = user.avatarUrl;console.log(avatarUrl); + const avatarUrl = user.avatarUrl; + console.log(avatarUrl); ``` color: |- ``` @@ -994,7 +1138,8 @@ ActiveUser: Example ``` - const userColor = user.color;console.log(userColor); + const userColor = user.color; + console.log(userColor); ``` sessionId: |- ``` @@ -1005,7 +1150,8 @@ ActiveUser: Example ``` - const sessionId = user.sessionId;console.log(sessionId); + const sessionId = user.sessionId; + console.log(sessionId); ``` Blur: overview: |- @@ -1151,6 +1297,10 @@ Board: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -1187,6 +1337,7 @@ Board: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -1567,6 +1718,10 @@ Board: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -1661,7 +1816,16 @@ Board: Example ``` - const board = penpot.createBoard();const flex = board.addFlexLayout();// You can change the flex properties as follows.flex.dir = "column";flex.wrap = "wrap";flex.alignItems = "center";flex.justifyContent = "center";flex.horizontalSizing = "fill";flex.verticalSizing = "fill"; + const board = penpot.createBoard(); + const flex = board.addFlexLayout(); + + // You can change the flex properties as follows. + flex.dir = "column"; + flex.wrap = "wrap"; + flex.alignItems = "center"; + flex.justifyContent = "center"; + flex.horizontalSizing = "fill"; + flex.verticalSizing = "fill"; ``` addGridLayout: |- ``` @@ -1676,7 +1840,16 @@ Board: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5; + const board = penpot.createBoard(); + const grid = board.addGridLayout(); + + // You can change the grid properties as follows. + grid.alignItems = "center"; + grid.justifyItems = "start"; + grid.rowGap = 10; + grid.columnGap = 10; + grid.verticalPadding = 5; + grid.horizontalPadding = 5; ``` addRulerGuide: |- ``` @@ -1734,7 +1907,8 @@ Board: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -1771,7 +1945,8 @@ Board: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -1795,7 +1970,8 @@ Board: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -1841,7 +2017,8 @@ Board: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -1971,6 +2148,18 @@ Board: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -2179,6 +2368,11 @@ Board: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -2326,6 +2520,10 @@ VariantContainer: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -2362,6 +2560,7 @@ VariantContainer: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -2745,6 +2944,10 @@ VariantContainer: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -2839,7 +3042,16 @@ VariantContainer: Example ``` - const board = penpot.createBoard();const flex = board.addFlexLayout();// You can change the flex properties as follows.flex.dir = "column";flex.wrap = "wrap";flex.alignItems = "center";flex.justifyContent = "center";flex.horizontalSizing = "fill";flex.verticalSizing = "fill"; + const board = penpot.createBoard(); + const flex = board.addFlexLayout(); + + // You can change the flex properties as follows. + flex.dir = "column"; + flex.wrap = "wrap"; + flex.alignItems = "center"; + flex.justifyContent = "center"; + flex.horizontalSizing = "fill"; + flex.verticalSizing = "fill"; ``` addGridLayout: |- ``` @@ -2854,7 +3066,16 @@ VariantContainer: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5; + const board = penpot.createBoard(); + const grid = board.addGridLayout(); + + // You can change the grid properties as follows. + grid.alignItems = "center"; + grid.justifyItems = "start"; + grid.rowGap = 10; + grid.columnGap = 10; + grid.verticalPadding = 5; + grid.horizontalPadding = 5; ``` addRulerGuide: |- ``` @@ -2912,7 +3133,8 @@ VariantContainer: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -2949,7 +3171,8 @@ VariantContainer: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -2973,7 +3196,8 @@ VariantContainer: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -3019,7 +3243,8 @@ VariantContainer: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -3149,6 +3374,18 @@ VariantContainer: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -3357,6 +3594,11 @@ VariantContainer: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -3491,6 +3733,10 @@ Boolean: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -3527,6 +3773,7 @@ Boolean: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -3864,6 +4111,10 @@ Boolean: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -3978,7 +4229,8 @@ Boolean: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -4015,7 +4267,8 @@ Boolean: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -4039,7 +4292,8 @@ Boolean: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -4085,7 +4339,8 @@ Boolean: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -4215,6 +4470,18 @@ Boolean: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -4423,6 +4690,11 @@ Boolean: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -5694,7 +5966,8 @@ Context: Example ``` - const rootShape = context.root;console.log(rootShape); + const rootShape = context.root; + console.log(rootShape); ``` currentFile: |- ``` @@ -5709,7 +5982,8 @@ Context: Example ``` - const fileData = context.currentFile;console.log(fileData); + const fileData = context.currentFile; + console.log(fileData); ``` currentPage: |- ``` @@ -5720,7 +5994,8 @@ Context: Example ``` - const currentPage = context.currentPage;console.log(currentPage); + const currentPage = context.currentPage; + console.log(currentPage); ``` viewport: |- ``` @@ -5731,7 +6006,8 @@ Context: Example ``` - const viewportSettings = context.viewport;console.log(viewportSettings); + const viewportSettings = context.viewport; + console.log(viewportSettings); ``` flags: |- ``` @@ -5748,7 +6024,8 @@ Context: Example ``` - const historyContext = context.history;console.log(historyContext); + const historyContext = context.history; + console.log(historyContext); ``` library: |- ``` @@ -5759,7 +6036,8 @@ Context: Example ``` - const libraryContext = context.library;console.log(libraryContext); + const libraryContext = context.library; + console.log(libraryContext); ``` fonts: |- ``` @@ -5770,7 +6048,8 @@ Context: Example ``` - const fontsContext = context.fonts;console.log(fontsContext); + const fontsContext = context.fonts; + console.log(fontsContext); ``` currentUser: |- ``` @@ -5781,7 +6060,8 @@ Context: Example ``` - const currentUser = context.currentUser;console.log(currentUser); + const currentUser = context.currentUser; + console.log(currentUser); ``` activeUsers: |- ``` @@ -5792,7 +6072,8 @@ Context: Example ``` - const activeUsers = context.activeUsers;console.log(activeUsers); + const activeUsers = context.activeUsers; + console.log(activeUsers); ``` theme: |- ``` @@ -5803,7 +6084,8 @@ Context: Example ``` - const currentTheme = context.theme;console.log(currentTheme); + const currentTheme = context.theme; + console.log(currentTheme); ``` localStorage: |- ``` @@ -5820,7 +6102,8 @@ Context: Example ``` - const selectedShapes = context.selection;console.log(selectedShapes); + const selectedShapes = context.selection; + console.log(selectedShapes); ``` Methods: shapesColors: |- @@ -5840,7 +6123,8 @@ Context: Example ``` - const colors = context.shapesColors(shapes);console.log(colors); + const colors = context.shapesColors(shapes); + console.log(colors); ``` replaceColor: |- ``` @@ -5883,7 +6167,14 @@ Context: Example ``` - const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');console.log(imageData);// to insert the image in a shape we can doconst board = penpot.createBoard();const shape = penpot.createRectangle();board.appendChild(shape);shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + console.log(imageData); + + // to insert the image in a shape we can do + const board = penpot.createBoard(); + const shape = penpot.createRectangle(); + board.appendChild(shape); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; ``` uploadMediaData: |- ``` @@ -5912,7 +6203,8 @@ Context: Example ``` - const imageData = await context.uploadMediaData('example', imageData, 'image/jpeg');console.log(imageData); + const imageData = await context.uploadMediaData('example', imageData, 'image/jpeg'); + console.log(imageData); ``` group: |- ``` @@ -5933,7 +6225,8 @@ Context: Example ``` - const penpotShapesArray = penpot.selection;penpot.group(penpotShapesArray); + const penpotShapesArray = penpot.selection; + penpot.group(penpotShapesArray); ``` ungroup: |- ``` @@ -5955,7 +6248,11 @@ Context: Example ``` - const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and that the selected shape is a groupif (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.ungroup(penpotShapesArray[0]);} + const penpotShapesArray = penpot.selection; + // We need to make sure that something is selected, and that the selected shape is a group + if (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { + penpot.ungroup(penpotShapesArray[0]); + } ``` createRectangle: |- ``` @@ -5968,7 +6265,48 @@ Context: Example ``` - const shape = penpot.createRectangle();// just change the values like thisshape.name = "Example rectangle";// for solid colorshape.fills = [{ fillColor: "#7EFFF5" }];// for linear gradient colorshape.fills = [{ fillColorGradient: { "type": "linear", "startX": 0.5, "startY": 0, "endX": 0.5, "endY": 1, "width": 1, "stops": [ { "color": "#003ae9", "opacity": 1, "offset": 0 }, { "color": "#003ae9", "opacity": 0, "offset": 1 } ] }}];// for a image fillconst imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');shape.fills = [{ fillOpacity: 1, fillImage: imageData }];shape.borderRadius = 8;shape.strokes = [ { strokeColor: "#2e3434", strokeStyle: "solid", strokeWidth: 2, strokeAlignment: "center", },]; + const shape = penpot.createRectangle(); + // just change the values like this + shape.name = "Example rectangle"; + + // for solid color + shape.fills = [{ fillColor: "#7EFFF5" }]; + // for linear gradient color + shape.fills = [{ + fillColorGradient: { + "type": "linear", + "startX": 0.5, + "startY": 0, + "endX": 0.5, + "endY": 1, + "width": 1, + "stops": [ + { + "color": "#003ae9", + "opacity": 1, + "offset": 0 + }, + { + "color": "#003ae9", + "opacity": 0, + "offset": 1 + } + ] + } + }]; + // for a image fill + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + + shape.borderRadius = 8; + shape.strokes = [ + { + strokeColor: "#2e3434", + strokeStyle: "solid", + strokeWidth: 2, + strokeAlignment: "center", + }, + ]; ``` createBoard: |- ``` @@ -5983,7 +6321,16 @@ Context: Example ``` - const board = penpot.createBoard();// to add grid layoutboard.addGridLayout();// to add flex layoutboard.addFlexLayout();// to create a shape inside the boardconst shape = penpot.createRectangle();board.appendChild(shape); + const board = penpot.createBoard(); + + // to add grid layout + board.addGridLayout(); + // to add flex layout + board.addFlexLayout(); + + // to create a shape inside the board + const shape = penpot.createRectangle(); + board.appendChild(shape); ``` createEllipse: |- ``` @@ -5996,7 +6343,47 @@ Context: Example ``` - const shape = penpot.createEllipse();// just change the values like thisshape.name = "Example ellipse";// for solid colorshape.fills = [{ fillColor: "#7EFFF5" }];// for linear gradient colorshape.fills = [{ fillColorGradient: { "type": "linear", "startX": 0.5, "startY": 0, "endX": 0.5, "endY": 1, "width": 1, "stops": [ { "color": "#003ae9", "opacity": 1, "offset": 0 }, { "color": "#003ae9", "opacity": 0, "offset": 1 } ] }}];// for an image fillconst imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg');shape.fills = [{ fillOpacity: 1, fillImage: imageData }];shape.strokes = [ { strokeColor: "#2e3434", strokeStyle: "solid", strokeWidth: 2, strokeAlignment: "center", },]; + const shape = penpot.createEllipse(); + // just change the values like this + shape.name = "Example ellipse"; + + // for solid color + shape.fills = [{ fillColor: "#7EFFF5" }]; + // for linear gradient color + shape.fills = [{ + fillColorGradient: { + "type": "linear", + "startX": 0.5, + "startY": 0, + "endX": 0.5, + "endY": 1, + "width": 1, + "stops": [ + { + "color": "#003ae9", + "opacity": 1, + "offset": 0 + }, + { + "color": "#003ae9", + "opacity": 0, + "offset": 1 + } + ] + } + }]; + // for an image fill + const imageData = await context.uploadMediaUrl('example', 'https://example.com/image.jpg'); + shape.fills = [{ fillOpacity: 1, fillImage: imageData }]; + + shape.strokes = [ + { + strokeColor: "#2e3434", + strokeStyle: "solid", + strokeWidth: 2, + strokeAlignment: "center", + }, + ]; ``` createPath: |- ``` @@ -6009,7 +6396,11 @@ Context: Example ``` - const path = penpot.createPath();path.name = "My path";// for solid colorpath.fills = [{ fillColor: "#7EFFF5" }]; + const path = penpot.createPath(); + path.name = "My path"; + + // for solid color + path.fills = [{ fillColor: "#7EFFF5" }]; ``` createBoolean: |- ``` @@ -6098,7 +6489,16 @@ Context: Example ``` - const board = penpot.createBoard();let text;text = penpot.createText();// just change the values like thistext.growType = 'auto-height';text.fontFamily = 'Work Sans';text.fontSize = '12';text.fills = [{fillColor: '#9f05ff', fillOpacity: 1}];text.strokes = [{strokeOpacity: 1, strokeStyle: 'solid', strokeWidth: 2, strokeColor: '#deabff', strokeAlignment: 'outer'}];board.appendChild(text); + const board = penpot.createBoard(); + let text; + text = penpot.createText(); + // just change the values like this + text.growType = 'auto-height'; + text.fontFamily = 'Work Sans'; + text.fontSize = '12'; + text.fills = [{fillColor: '#9f05ff', fillOpacity: 1}]; + text.strokes = [{strokeOpacity: 1, strokeStyle: 'solid', strokeWidth: 2, strokeColor: '#deabff', strokeAlignment: 'outer'}]; + board.appendChild(text); ``` generateMarkup: |- ``` @@ -6120,7 +6520,8 @@ Context: Example ``` - const markup = context.generateMarkup(shapes, { type: 'html' });console.log(markup); + const markup = context.generateMarkup(shapes, { type: 'html' }); + console.log(markup); ``` generateStyle: |- ``` @@ -6150,7 +6551,8 @@ Context: Example ``` - const styles = context.generateStyle(shapes, { type: 'css' });console.log(styles); + const styles = context.generateStyle(shapes, { type: 'css' }); + console.log(styles); ``` generateFontFaces: |- ``` @@ -6168,7 +6570,8 @@ Context: Example ``` - const fontfaces = context.generateFontFaces(penpot.selection);console.log(fontfaces); + const fontfaces = context.generateFontFaces(penpot.selection); + console.log(fontfaces); ``` addListener: |- ``` @@ -6195,7 +6598,9 @@ Context: Example ``` - const listenerId = context.addListener('selectionchange', (event) => { console.log(event);}); + const listenerId = context.addListener('selectionchange', (event) => { + console.log(event); + }); ``` removeListener: |- ``` @@ -6237,7 +6642,12 @@ Context: Example ``` - const page = penpot.createPage();page.name = 'New Page';await penpot.openPage(page); // make the new page active firstconst board = penpot.createBoard();board.resize(375, 812);page.root.appendChild(board); + const page = penpot.createPage(); + page.name = 'New Page'; + await penpot.openPage(page); // make the new page active first + const board = penpot.createBoard(); + board.resize(375, 812); + page.root.appendChild(board); ``` openPage: |- ``` @@ -6392,7 +6802,8 @@ ContextGeometryUtils: Example ``` - const centerPoint = geometryUtils.center(shapes);console.log(centerPoint); + const centerPoint = geometryUtils.center(shapes); + console.log(centerPoint); ``` ``` @@ -6424,7 +6835,8 @@ ContextGeometryUtils: Example ``` - const centerPoint = geometryUtils.center(shapes);console.log(centerPoint); + const centerPoint = geometryUtils.center(shapes); + console.log(centerPoint); ``` ContextTypesUtils: overview: |- @@ -6435,7 +6847,8 @@ ContextTypesUtils: Example ``` - const isBoard = typesUtils.isBoard(shape);console.log(isBoard); + const isBoard = typesUtils.isBoard(shape); + console.log(isBoard); ``` ``` @@ -6664,7 +7077,8 @@ ContextUtils: Example ``` - const centerPoint = penpot.utils.geometry.center(shapes);console.log(centerPoint); + const centerPoint = penpot.utils.geometry.center(shapes); + console.log(centerPoint); ``` types: |- ``` @@ -6676,7 +7090,8 @@ ContextUtils: Example ``` - const isBoard = utils.types.isBoard(shape);console.log(isBoard); + const isBoard = utils.types.isBoard(shape); + console.log(isBoard); ``` Dissolve: overview: |- @@ -6802,6 +7217,10 @@ Ellipse: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -6838,6 +7257,7 @@ Ellipse: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -7147,6 +7567,10 @@ Ellipse: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -7206,7 +7630,8 @@ Ellipse: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -7243,7 +7668,8 @@ Ellipse: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -7267,7 +7693,8 @@ Ellipse: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -7313,7 +7740,8 @@ Ellipse: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -7443,6 +7871,18 @@ Ellipse: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -7651,6 +8091,11 @@ Ellipse: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -7701,7 +8146,9 @@ EventsMap: Example ``` - penpot.on('pagechange', (event) => { console.log(event);}); + penpot.on('pagechange', (event) => { + console.log(event); + }); ``` ``` @@ -7950,7 +8397,8 @@ File: Example ``` - const errors = file.validate();if (errors.length) console.error(errors); + const errors = file.validate(); + if (errors.length) console.error(errors); ``` getPluginData: |- ``` @@ -7971,7 +8419,8 @@ File: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -8008,7 +8457,8 @@ File: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -8032,7 +8482,8 @@ File: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -8078,7 +8529,8 @@ File: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` FileValidationError: overview: |- @@ -8799,7 +9251,10 @@ FontsContext: Example ``` - const font = fontsContext.findById('font-id');if (font) { console.log(font.name);} + const font = fontsContext.findById('font-id'); + if (font) { + console.log(font.name); + } ``` findByName: |- ``` @@ -8820,7 +9275,10 @@ FontsContext: Example ``` - const font = fontsContext.findByName('font-name');if (font) { console.log(font.name);} + const font = fontsContext.findByName('font-name'); + if (font) { + console.log(font.name); + } ``` findAllById: |- ``` @@ -8841,7 +9299,8 @@ FontsContext: Example ``` - const fonts = fontsContext.findAllById('font-id');console.log(fonts); + const fonts = fontsContext.findAllById('font-id'); + console.log(fonts); ``` findAllByName: |- ``` @@ -8862,7 +9321,8 @@ FontsContext: Example ``` - const fonts = fontsContext.findAllByName('font-name');console.log(fonts); + const fonts = fontsContext.findAllByName('font-name'); + console.log(fonts); ``` GridLayout: overview: |- @@ -9129,7 +9589,9 @@ GridLayout: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();grid.addRow("flex", 1); + const board = penpot.createBoard(); + const grid = board.addGridLayout(); + grid.addRow("flex", 1); ``` addRowAtIndex: |- ``` @@ -9176,7 +9638,9 @@ GridLayout: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();grid.addColumn('percent', 50); + const board = penpot.createBoard(); + const grid = board.addGridLayout(); + grid.addColumn('percent', 50); ``` addColumnAtIndex: |- ``` @@ -9410,6 +9874,10 @@ Group: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -9446,6 +9914,7 @@ Group: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -9759,6 +10228,10 @@ Group: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -9884,7 +10357,8 @@ Group: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -9921,7 +10395,8 @@ Group: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -9945,7 +10420,8 @@ Group: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -9991,7 +10467,8 @@ Group: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -10121,6 +10598,18 @@ Group: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -10329,6 +10818,11 @@ Group: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -10713,6 +11207,10 @@ Image: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -10749,6 +11247,7 @@ Image: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -11058,6 +11557,10 @@ Image: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -11117,7 +11620,8 @@ Image: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -11154,7 +11658,8 @@ Image: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -11178,7 +11683,8 @@ Image: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -11224,7 +11730,8 @@ Image: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -11354,6 +11861,18 @@ Image: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -11562,6 +12081,11 @@ Image: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -11889,6 +12413,7 @@ Library: colors: LibraryColor[]; typographies: LibraryTypography[]; components: LibraryComponent[]; + deletedComponents: LibraryComponent[]; tokens: TokenCatalog; createColor(): LibraryColor; createTypography(): LibraryTypography; @@ -11950,6 +12475,13 @@ Library: ``` console.log(penpot.library.local.components ``` + deletedComponents: |- + ``` + readonly deletedComponents: LibraryComponent[] + ``` + + Deleted components, including individual deleted variants. + Use a component's `restore()` method to restore it. tokens: |- ``` readonly tokens: TokenCatalog @@ -11972,7 +12504,8 @@ Library: Example ``` - const newColor = penpot.library.local.createColor();console.log(newColor); + const newColor = penpot.library.local.createColor(); + console.log(newColor); ``` createTypography: |- ``` @@ -12029,7 +12562,8 @@ Library: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -12066,7 +12600,8 @@ Library: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -12090,7 +12625,8 @@ Library: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -12136,7 +12672,8 @@ Library: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LibraryColor: overview: |- @@ -12273,7 +12810,8 @@ LibraryColor: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -12310,7 +12848,8 @@ LibraryColor: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -12334,7 +12873,8 @@ LibraryColor: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -12380,7 +12920,8 @@ LibraryColor: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LibraryComponent: overview: |- @@ -12392,6 +12933,10 @@ LibraryComponent: ``` interface LibraryComponent { + annotation: string | null; + duplicate(): LibraryComponent; + remove(): void; + restore(): void; instance(): Shape; mainInstance(): Shape; isVariant(): this is LibraryVariantComponent; @@ -12415,9 +12960,18 @@ LibraryComponent: + LibraryComponent - LibraryVariantComponent - Referenced by: Board, Boolean, ContextTypesUtils, Ellipse, Group, Image, Library, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer, Variants + Referenced by: Board, Boolean, ContextTypesUtils, Ellipse, Group, Image, Library, LibraryComponent, LibraryVariantComponent, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer, Variants members: Properties: + annotation: |- + ``` + annotation: string | null + ``` + + The component annotation, or null when none is set. + Set null to delete it. Text can contain at most 300 characters. + Writing requires `library:write` permission and a live component in the + current file's library. id: |- ``` readonly id: string @@ -12443,6 +12997,41 @@ LibraryComponent: The path of the library element. Methods: + duplicate: |- + ``` + duplicate(): LibraryComponent + ``` + + Duplicates this component and its main instance in the current file's library. + Requires `library:write` permission. The component must not be deleted. + + Returns LibraryComponent + remove: |- + ``` + remove(): void + ``` + + Deletes this component and its main instance from the current file's library. + Requires `library:write` permission. Does nothing if already deleted. + Surviving copies keep their link so the component can be restored later. + + Returns void + restore: |- + ``` + restore(): void + ``` + + Restores this component if it has been deleted, including its main instance, + in the current file's library. + Leaves the current page, selection, zoom, and viewport unchanged. + Requires `library:write` permission. Does nothing if already restored. + + Returns void + + Example + ``` + penpot.selection[0].component()?.restore(); + ``` instance: |- ``` instance(): Shape @@ -12505,7 +13094,8 @@ LibraryComponent: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -12542,7 +13132,8 @@ LibraryComponent: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -12566,7 +13157,8 @@ LibraryComponent: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -12612,7 +13204,8 @@ LibraryComponent: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LibraryVariantComponent: overview: |- @@ -12624,6 +13217,10 @@ LibraryVariantComponent: ``` interface LibraryVariantComponent { + annotation: string | null; + duplicate(): LibraryComponent; + remove(): void; + restore(): void; instance(): Shape; mainInstance(): Shape; isVariant(): this is LibraryVariantComponent; @@ -12654,6 +13251,15 @@ LibraryVariantComponent: Referenced by: ContextTypesUtils, LibraryComponent, LibraryVariantComponent members: Properties: + annotation: |- + ``` + annotation: string | null + ``` + + The component annotation, or null when none is set. + Set null to delete it. Text can contain at most 300 characters. + Writing requires `library:write` permission and a live component in the + current file's library. variants: |- ``` readonly variants: Variants | null @@ -12698,6 +13304,41 @@ LibraryVariantComponent: The path of the library element. Methods: + duplicate: |- + ``` + duplicate(): LibraryComponent + ``` + + Duplicates this component and its main instance in the current file's library. + Requires `library:write` permission. The component must not be deleted. + + Returns LibraryComponent + remove: |- + ``` + remove(): void + ``` + + Deletes this component and its main instance from the current file's library. + Requires `library:write` permission. Does nothing if already deleted. + Surviving copies keep their link so the component can be restored later. + + Returns void + restore: |- + ``` + restore(): void + ``` + + Restores this component if it has been deleted, including its main instance, + in the current file's library. + Leaves the current page, selection, zoom, and viewport unchanged. + Requires `library:write` permission. Does nothing if already restored. + + Returns void + + Example + ``` + penpot.selection[0].component()?.restore(); + ``` instance: |- ``` instance(): Shape @@ -12785,7 +13426,8 @@ LibraryVariantComponent: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -12822,7 +13464,8 @@ LibraryVariantComponent: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -12846,7 +13489,8 @@ LibraryVariantComponent: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -12892,7 +13536,8 @@ LibraryVariantComponent: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LibraryElement: overview: |- @@ -12970,7 +13615,8 @@ LibraryElement: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -13007,7 +13653,8 @@ LibraryElement: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -13031,7 +13678,8 @@ LibraryElement: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -13077,7 +13725,8 @@ LibraryElement: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LibrarySummary: overview: |- @@ -13331,7 +13980,8 @@ LibraryTypography: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -13368,7 +14018,8 @@ LibraryTypography: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -13392,7 +14043,8 @@ LibraryTypography: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -13438,7 +14090,8 @@ LibraryTypography: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` LocalStorage: overview: |- @@ -13869,7 +14522,8 @@ Page: Example ``` - const page = penpot.createPage();page.remove(); + const page = penpot.createPage(); + page.remove(); ``` getShapeById: |- ``` @@ -14075,7 +14729,8 @@ Page: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -14112,7 +14767,8 @@ Page: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -14136,7 +14792,8 @@ Page: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -14182,7 +14839,8 @@ Page: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` Path: overview: |- @@ -14275,6 +14933,10 @@ Path: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -14311,6 +14973,7 @@ Path: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -14642,6 +15305,10 @@ Path: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -14715,7 +15382,8 @@ Path: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -14752,7 +15420,8 @@ Path: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -14776,7 +15445,8 @@ Path: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -14822,7 +15492,8 @@ Path: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -14952,6 +15623,18 @@ Path: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -15160,6 +15843,11 @@ Path: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -15394,7 +16082,8 @@ PluginData: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -15431,7 +16120,8 @@ PluginData: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -15455,7 +16145,8 @@ PluginData: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -15501,7 +16192,8 @@ PluginData: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` PreviousScreen: overview: |- @@ -15656,6 +16348,10 @@ Rectangle: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -15692,6 +16388,7 @@ Rectangle: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -16001,6 +16698,10 @@ Rectangle: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -16060,7 +16761,8 @@ Rectangle: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -16097,7 +16799,8 @@ Rectangle: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -16121,7 +16824,8 @@ Rectangle: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -16167,7 +16871,8 @@ Rectangle: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -16297,6 +17002,18 @@ Rectangle: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -16505,6 +17222,11 @@ Rectangle: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -16756,6 +17478,10 @@ ShapeBase: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -16792,6 +17518,7 @@ ShapeBase: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -17100,6 +17827,10 @@ ShapeBase: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -17159,7 +17890,8 @@ ShapeBase: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -17196,7 +17928,8 @@ ShapeBase: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -17220,7 +17953,8 @@ ShapeBase: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -17266,7 +18000,8 @@ ShapeBase: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -17396,6 +18131,18 @@ ShapeBase: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -17604,6 +18351,11 @@ ShapeBase: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -17707,8 +18459,8 @@ Stroke: Interface Stroke ================ - Represents stroke properties in Penpot. You can add a stroke to any shape except for groups. - This interface includes properties for defining the color, style, width, alignment, and caps of a stroke. + Stroke properties for any shape except groups. + Literals apply strokeWidth before top/right/bottom/left widths. ``` interface Stroke { @@ -17718,6 +18470,11 @@ Stroke: strokeOpacity?: number; strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"; strokeWidth?: number; + strokeWidthType?: "simple" | "multiple"; + strokeWidthTop?: number; + strokeWidthRight?: number; + strokeWidthBottom?: number; + strokeWidthLeft?: number; strokeAlignment?: "center" | "inner" | "outer"; strokeCapStart?: StrokeCap; strokeCapEnd?: StrokeCap; @@ -17765,7 +18522,41 @@ Stroke: strokeWidth?: number ``` - The optional width of the stroke. + Reads the top width. Setting it selects simple mode and sets all four sides. + strokeWidthType: |- + ``` + readonly strokeWidthType?: "simple" | "multiple" + ``` + + Computed width mode: simple when all four effective widths match, multiple otherwise. + strokeWidthTop: |- + ``` + strokeWidthTop?: number + ``` + + Top side width; setting it preserves the other side widths. + Boards and rectangles only; other shape types raise a validation error. + strokeWidthRight: |- + ``` + strokeWidthRight?: number + ``` + + Right side width; setting it preserves the other side widths. + Boards and rectangles only; other shape types raise a validation error. + strokeWidthBottom: |- + ``` + strokeWidthBottom?: number + ``` + + Bottom side width; setting it preserves the other side widths. + Boards and rectangles only; other shape types raise a validation error. + strokeWidthLeft: |- + ``` + strokeWidthLeft?: number + ``` + + Left side width; setting it preserves the other side widths. + Boards and rectangles only; other shape types raise a validation error. strokeAlignment: |- ``` strokeAlignment?: "center" | "inner" | "outer" @@ -17883,6 +18674,10 @@ SvgRaw: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -17919,6 +18714,7 @@ SvgRaw: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -18221,6 +19017,10 @@ SvgRaw: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -18286,7 +19086,8 @@ SvgRaw: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -18323,7 +19124,8 @@ SvgRaw: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -18347,7 +19149,8 @@ SvgRaw: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -18393,7 +19196,8 @@ SvgRaw: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -18523,6 +19327,18 @@ SvgRaw: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -18731,6 +19547,11 @@ SvgRaw: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -18858,6 +19679,10 @@ Text: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -18894,6 +19719,7 @@ Text: detach(): void; swapComponent(component: LibraryComponent): void; resetOverrides(): void; + updateMain(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -19214,6 +20040,10 @@ Text: shadow: string; strokeColor: string; strokeWidth: string; + strokeWidthTop: string; + strokeWidthRight: string; + strokeWidthBottom: string; + strokeWidthLeft: string; fontFamilies: string; fontSize: string; fontWeight: string; @@ -19381,7 +20211,8 @@ Text: Example ``` - const data = shape.getPluginData('exampleKey');console.log(data); + const data = shape.getPluginData('exampleKey'); + console.log(data); ``` setPluginData: |- ``` @@ -19418,7 +20249,8 @@ Text: Example ``` - const keys = shape.getPluginDataKeys();console.log(keys); + const keys = shape.getPluginDataKeys(); + console.log(keys); ``` getSharedPluginData: |- ``` @@ -19442,7 +20274,8 @@ Text: Example ``` - const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey');console.log(sharedData); + const sharedData = shape.getSharedPluginData('exampleNamespace', 'exampleKey'); + console.log(sharedData); ``` setSharedPluginData: |- ``` @@ -19488,7 +20321,8 @@ Text: Example ``` - const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); + const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace'); + console.log(sharedKeys); ``` setParentIndex: |- ``` @@ -19618,6 +20452,18 @@ Text: Similar to the "reset overrides" action on the Penpot interface. The current shape must be a component copy instance. + Returns void + updateMain: |- + ``` + updateMain(): void + ``` + + Applies this copy's overrides to its main component and synchronizes copies. + Similar to the editor's "update main component" action. + Requires `content:write` and `library:write` permissions. + The shape must be a component copy head on the active page of the current file, + outside any other component copy, with a main component that is not deleted. + Returns void switchVariant: |- ``` @@ -19826,6 +20672,11 @@ Text: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties (`strokeWidthTop`, + `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + boards and rectangles. Applying one to any other shape raises a validation + error. The uniform `strokeWidth` property is accepted on every shape. + Returns void clone: |- ``` @@ -19888,7 +20739,8 @@ Text: Example ``` - const textRange = textShape.getRange(0, 10);console.log(textRange.characters); + const textRange = textShape.getRange(0, 10); + console.log(textRange.characters); ``` applyTypography: |- ``` @@ -20323,6 +21175,10 @@ TokenBase: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -20464,6 +21320,10 @@ TokenBorderRadius: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -20727,6 +21587,10 @@ TokenShadow: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -20868,6 +21732,10 @@ TokenColor: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21009,6 +21877,10 @@ TokenDimension: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21153,6 +22025,10 @@ TokenFontFamilies: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21294,6 +22170,10 @@ TokenFontSizes: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21436,6 +22316,10 @@ TokenFontWeights: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21577,6 +22461,10 @@ TokenLetterSpacing: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21718,6 +22606,10 @@ TokenNumber: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -21860,6 +22752,10 @@ TokenOpacity: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22002,6 +22898,10 @@ TokenRotation: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22143,6 +23043,10 @@ TokenSizing: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22284,6 +23188,10 @@ TokenSpacing: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22425,6 +23333,10 @@ TokenBorderWidth: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22567,6 +23479,10 @@ TokenTextCase: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22709,6 +23625,10 @@ TokenTextDecoration: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -22985,6 +23905,10 @@ TokenTypography: and the value set to the attributes will depend on which sets are active (and will change if different sets or themes are activated later). + NOTE that the per-side stroke width properties are only valid on boards and + rectangles; applying one to any other shape raises a validation error. The + uniform `strokeWidth` property is accepted on every shape. + Returns void applyToSelected: |- ``` @@ -23414,7 +24338,8 @@ User: Example ``` - const userId = user.id;console.log(userId); + const userId = user.id; + console.log(userId); ``` name: |- ``` @@ -23425,7 +24350,8 @@ User: Example ``` - const userName = user.name;console.log(userName); + const userName = user.name; + console.log(userName); ``` avatarUrl: |- ``` @@ -23436,7 +24362,8 @@ User: Example ``` - const avatarUrl = user.avatarUrl;console.log(avatarUrl); + const avatarUrl = user.avatarUrl; + console.log(avatarUrl); ``` color: |- ``` @@ -23447,7 +24374,8 @@ User: Example ``` - const userColor = user.color;console.log(userColor); + const userColor = user.color; + console.log(userColor); ``` sessionId: |- ``` @@ -23458,7 +24386,8 @@ User: Example ``` - const sessionId = user.sessionId;console.log(sessionId); + const sessionId = user.sessionId; + console.log(sessionId); ``` Variants: overview: |- @@ -23703,7 +24632,8 @@ Bounds: Example ``` - const bounds = { x: 50, y: 50, width: 200, height: 100 };console.log(bounds); + const bounds = { x: 50, y: 50, width: 200, height: 100 }; + console.log(bounds); ``` ``` @@ -24066,7 +24996,10 @@ Shape: Example ``` - let shape: Shape;if (penpot.utils.types.isRectangle(shape)) { console.log(shape.type);} + let shape: Shape; + if (penpot.utils.types.isRectangle(shape)) { + console.log(shape.type); + } ``` Referenced by: Board, Boolean, Context, ContextGeometryUtils, ContextTypesUtils, Ellipse, EventsMap, FlexLayout, GridLayout, Group, Image, Interaction, Library, LibraryComponent, LibraryTypography, LibraryVariantComponent, OpenOverlay, OverlayAction, Page, Path, Penpot, Rectangle, ShapeBase, SvgRaw, Text, ToggleOverlay, TokenBase, TokenBorderRadius, TokenBorderWidth, TokenColor, TokenDimension, TokenFontFamilies, TokenFontSizes, TokenFontWeights, TokenLetterSpacing, TokenNumber, TokenOpacity, TokenRotation, TokenShadow, TokenSizing, TokenSpacing, TokenTextCase, TokenTextDecoration, TokenTypography, VariantContainer, Viewport @@ -24239,7 +25172,14 @@ TokenDimensionProps: ============================== ``` - TokenDimensionProps: "x" | "y" | "strokeWidth" + TokenDimensionProps: + | "x" + | "y" + | "strokeWidth" + | "strokeWidthTop" + | "strokeWidthRight" + | "strokeWidthBottom" + | "strokeWidthLeft" ``` The properties that a Dimension token can be applied to. @@ -24372,7 +25312,12 @@ TokenBorderWidthProps: ================================ ``` - TokenBorderWidthProps: "strokeWidth" + TokenBorderWidthProps: + | "strokeWidth" + | "strokeWidthTop" + | "strokeWidthRight" + | "strokeWidthBottom" + | "strokeWidthLeft" ``` The properties that a BorderWidth token can be applied to. diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index e759903a7f..fbca1c5bad 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -19,10 +19,12 @@ This allows you to inspect intermediate results while still being able to build ## Asynchronous Updates Changes made to a design may take effect asynchronously. -So if you need to read the result of your executions/observe properties affected by a change in the same `execude_code` call, use +So if you need to read the result of your executions/observe properties affected by a change in the same `execute_code` call, use `await penpot.waitForLayoutUpdate();` before trying to observe changes. -Every `execude_code` call waits for updates before applying your code, so you never need to call `waitForLayoutUpdate` at the beginning of your code. +To wait only for a single shape (and its children), use `await shape.waitForLayoutUpdate();`. +Both accept an optional timeout in milliseconds (default 30000) and reject when it elapses. +Every `execute_code` call waits for updates before applying your code, so you never need to call `waitForLayoutUpdate` at the beginning of your code. ## Rejected Changes and Read-Only Mode @@ -73,6 +75,8 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` The objects are not shared references; you can modify properties of the fills in the target shape without affecting the source shape. - Colors: Use hex strings with caps only (e.g. '#FF5533') - IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them! + - Per-side stroke widths (boards and rectangles only; other shapes raise a validation error): `strokeWidthTop`, `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`. + Setting `strokeWidth` sets all four sides. Read-only `strokeWidthType` is `"simple"` when all four widths match, `"multiple"` otherwise. * `borderRadius` - Uniform border radius for all corners * `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii. * `blur: Blur` - Blur properties @@ -222,6 +226,9 @@ General pointers for working with Penpot designs: * Prefer `penpotUtils` helper functions — avoid reimplementing shape searching. * To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`. Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s). + * IMPORTANT: Shapes can only be created or modified on the active page; changes to shapes on other pages raise a validation error. + To work on another page, first activate it with `await penpot.openPage(page)`. + `penpot.createPage()` does NOT activate the new page; call `openPage` before adding shapes to it. * Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently. Common tasks - Quick Reference (ALWAYS use penpotUtils for these): @@ -311,6 +318,17 @@ Detaching: * When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification. * Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work. +Syncing copies and main components: + * `copy.resetOverrides()` - restores a copy (and its children) to the attributes of its main component. + * `copy.updateMain()` - applies the overrides of a copy to its main component and syncs all copies. + The copy must be a copy head on the active page, not nested in another copy. + +Managing components (current file's library only): + * `component.remove()` - deletes the component and its main instance; existing copies keep their link. + * `library.deletedComponents: LibraryComponent[]` - deleted components; `component.restore()` brings one back. + * `component.duplicate(): LibraryComponent` - duplicates the component and its main instance. + * `component.annotation: string | null` - writable annotation (max. 300 characters); set `null` to delete it. + ### Variants Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances. @@ -373,11 +391,16 @@ The token library: `penpot.library.local.tokens` (type: `TokenCatalog`) * `addSet({name: string}): TokenSet` - Create new set * `addTheme({group: string, name: string}): TokenTheme` - Create new theme +Token source: `penpot.library.tokensSource: Library` is the library that supplies the file's tokens (default: `penpot.library.local`). + * Assigning a connected library makes its tokens the file's tokens; assign `penpot.library.local` to use local tokens again. + * With an external source, `penpot.library.local.tokens` lists the source's tokens and is where you toggle active sets/themes; + the source library's own catalog is read-only. + `TokenSet` contains tokens with unique names: * `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();` * `tokens: Token[]` - All tokens in set * `addToken({type: TokenType, name: string, value: TokenValueString}): Token` - Creates a token, adding it to the set. - - `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase" + - `TokenType`: "color" | "dimension" | "spacing" | "sizing" | "typography" | "shadow" | "opacity" | "number" | "rotation" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase" - `value`: depends on the type of token (inspect `Token` and related types) - Examples: const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value @@ -403,7 +426,7 @@ Applying tokens: - TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft" - TokenShadowProps: "shadow" - TokenColorProps: "fill", "strokeColor" - - TokenDimensionProps: "x", "y", "strokeWidth" + - TokenDimensionProps: "x", "y", "strokeWidth", "strokeWidthTop", "strokeWidthRight", "strokeWidthBottom", "strokeWidthLeft" - TokenFontFamiliesProps: "fontFamilies" - TokenFontSizesProps: "fontSize" - TokenFontWeightProps: "fontWeight" @@ -412,10 +435,11 @@ Applying tokens: - TokenOpacityProps: "opacity" - TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH" - TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom" - - TokenBorderWidthProps: "strokeWidth" + - TokenBorderWidthProps: "strokeWidth", "strokeWidthTop", "strokeWidthRight", "strokeWidthBottom", "strokeWidthLeft" - TokenTextCaseProps: "textCase" - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" + The per-side stroke width properties apply only to boards and rectangles; `strokeWidth` works on every shape. * `token.applyToShapes(shapes, properties)` - Apply from token * Application is **asynchronous** (use `waitForLayoutUpdate`) * After application: diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 130dc315db..f005fb60ac 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -204,8 +204,8 @@ importers: packages/plugin: dependencies: '@penpot/plugin-styles': - specifier: 1.5.0 - version: 1.5.0 + specifier: 1.6.0 + version: 1.6.0 '@penpot/plugin-types': specifier: link:../../../plugins/libs/plugin-types version: link:../../../plugins/libs/plugin-types @@ -675,8 +675,8 @@ packages: '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} - '@penpot/plugin-styles@1.5.0': - resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} + '@penpot/plugin-styles@1.6.0': + resolution: {integrity: sha512-vmIDYbQqIcTPxYer7/C1nmo5QJyErTI4ZcFNELuENMnLcs+UeAFWtqsMENGoY6jfldEujbUwoglS9hBKI11x5Q==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1874,7 +1874,7 @@ snapshots: '@oxc-project/types@0.155.0': {} - '@penpot/plugin-styles@1.5.0': {} + '@penpot/plugin-styles@1.6.0': {} '@pinojs/redact@0.4.0': {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 5aa8300f88..6e1adb0a9f 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -14,6 +14,8 @@ minimumReleaseAgeExclude: - fast-uri@3.1.3 || 3.1.4 || 3.1.5 - ip-address@10.2.1 || 10.2.2 || 10.3.1 - hono@4.12.34 + - "@penpot/plugin-styles@1.6.0" + - "@penpot/plugin-types@1.6.0" packages: - "packages/common" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index deef90a3e6..5a6b8cd350 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,4 +1,4 @@ -## 1.6.0 (Unreleased) +## 1.6.0 (2026-11-10) ### 🚀 Features diff --git a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts index 451e75f91b..ace7b44d32 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/variants.test.ts @@ -41,6 +41,27 @@ function sameIds(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } +// Value of the first property for each variant component, in variant order. +function firstPropertyValues(container: VariantContainer): string[] { + const variants = container.variants; + if (!variants) return []; + const property = variants.properties[0]; + return variants + .variantComponents() + .map((c) => (c as LibraryVariantComponent).variantProps[property]); +} + +function variantValueOf( + container: VariantContainer, + componentId: string, +): string | undefined { + const variants = container.variants; + const component = variants + ?.variantComponents() + .find((c) => c.id === componentId) as LibraryVariantComponent | undefined; + return component?.variantProps[variants!.properties[0]]; +} + // Polls until the condition holds. On timeout it returns normally so the // assertion that follows reports the actual mismatch. async function waitFor( @@ -134,6 +155,72 @@ describe('Variants', () => { expect(variantComponentIds(container)).toEqual(expected); }); + // The components built here all share the name "Rectangle". Combining + // components with the same name must give every variant a unique, + // non-empty value, numbered in the combine order (#12878). + test('createVariantFromComponents gives same-name components unique values in input order', async (ctx) => { + const a = componentWithMain(ctx); + const b = componentWithMain(ctx); + const c = componentWithMain(ctx); + const container = ctx.penpot.createVariantFromComponents([ + c.main, + a.main, + b.main, + ]); + const expected = ['Value 1', 'Value 2', 'Value 3']; + await waitFor(() => sameIds(firstPropertyValues(container), expected)); + expect(firstPropertyValues(container)).toEqual(expected); + expect(variantComponentIds(container)).toEqual([ + c.comp.id, + a.comp.id, + b.comp.id, + ]); + }); + + test('combineAsVariants gives same-name components unique values in combine order', async (ctx) => { + const a = componentWithMain(ctx); + const b = componentWithMain(ctx); + const c = componentWithMain(ctx); + const container = a.main.combineAsVariants([b.main.id, c.main.id]); + const expected = ['Value 1', 'Value 2', 'Value 3']; + await waitFor(() => sameIds(firstPropertyValues(container), expected)); + expect(firstPropertyValues(container)).toEqual(expected); + expect(variantComponentIds(container)).toEqual([ + a.comp.id, + b.comp.id, + c.comp.id, + ]); + }); + + test('a same-name main moved into a variant container takes the first free value', async (ctx) => { + const a = componentWithMain(ctx); + const b = componentWithMain(ctx); + const c = componentWithMain(ctx); + const container = ctx.penpot.createVariantFromComponents([ + a.main, + b.main, + c.main, + ]); + await waitFor(() => firstPropertyValues(container).includes('Value 2')); + + // Free "Value 2" so the moved main has a gap to fill. + const withValue2 = container + .variants!.variantComponents() + .find( + (comp) => variantValueOf(container, comp.id) === 'Value 2', + ) as LibraryVariantComponent; + withValue2.setVariantProperty(0, 'Renamed'); + await waitFor(() => !firstPropertyValues(container).includes('Value 2')); + + const d = componentWithMain(ctx); + container.appendChild(d.main); + await waitFor(() => variantValueOf(container, d.comp.id) !== undefined); + expect(variantValueOf(container, d.comp.id)).toBe('Value 2'); + + const values = firstPropertyValues(container); + expect(new Set(values).size).toBe(values.length); + }); + test('variant component exposes variant props and Variants', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 6804ca045a..cd738a4fdc 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/plugin-types", - "version": "1.5.0", + "version": "1.6.0", "typings": "./index.d.ts", "type": "module", "scripts": { diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index 781ccb98c5..337f6cd9af 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -1,8 +1,8 @@ { "name": "@penpot/plugins-runtime", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { - "@penpot/plugin-types": "workspace:^", + "@penpot/plugin-types": "^1.6.0", "ses": "^2.3.0", "zod": "^4.6.5" }, diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 328ddc7c00..c70d423063 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/plugin-styles", - "version": "1.5.0", + "version": "1.6.0", "dependencies": {}, "scripts": { "build": "node ../../tools/scripts/build-css.mjs", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index c27b995baa..94c234158b 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -347,7 +347,7 @@ importers: version: 0.14.5(eslint@10.12.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)) vite-plugin-dts: specifier: 5.1.2 - version: 5.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)) + version: 5.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)) vite-plugin-static-copy: specifier: ^4.1.1 version: 4.1.1(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)) @@ -380,10 +380,10 @@ importers: version: 7.0.2 vite: specifier: ^8.3.2 - version: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0) + version: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)) + version: 0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)) apps/contrast-plugin: {} @@ -416,7 +416,7 @@ importers: libs/plugins-runtime: dependencies: '@penpot/plugin-types': - specifier: workspace:^ + specifier: ^1.6.0 version: link:../plugin-types ses: specifier: ^2.3.0 @@ -1576,9 +1576,6 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@oxc-project/types@0.149.0': resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} @@ -1710,12 +1707,6 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.2.2': - resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-android-arm64@1.2.8': resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1728,12 +1719,6 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.2.2': - resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@rolldown/binding-darwin-arm64@1.2.8': resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1746,12 +1731,6 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.2': - resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1764,12 +1743,6 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.2.2': - resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@rolldown/binding-freebsd-x64@1.2.8': resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1782,12 +1755,6 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.2.2': - resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1801,13 +1768,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.2.2': - resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.2.8': resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1822,13 +1782,6 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.2.2': - resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.2.8': resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1843,13 +1796,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-ppc64-gnu@1.2.2': - resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1864,13 +1810,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.2': - resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.8': resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1885,13 +1824,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.2': - resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.8': resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1906,13 +1838,6 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-musl@1.2.2': - resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-x64-musl@1.2.8': resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1926,12 +1851,6 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.2.2': - resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.2.8': resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1944,12 +1863,6 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.2.2': - resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.2.8': resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1962,12 +1875,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.2': - resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2846,10 +2753,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2887,9 +2790,6 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - colorjs.io@0.5.2: - resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} - colorjs.io@0.7.1: resolution: {integrity: sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==} @@ -3511,6 +3411,7 @@ packages: image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} + deprecated: Please migrate to image-size@2.x hasBin: true immutable@5.1.9: @@ -4261,10 +4162,6 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -4307,11 +4204,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.2.2: - resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4344,82 +4236,42 @@ packages: resolution: {integrity: sha512-toyMCyUKkT6DsYlHuKZGjd8T6WltaW4TGX07F7HnF4tNJM5m/8VaVsd74zrq3QSPqGE9JjxIDK4NrNc/QY9pkA==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-all-unknown@1.97.3: - resolution: {integrity: sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==} - cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.104.1: resolution: {integrity: sha512-yW3BVW4YFpN2vu2cVQ+Pz5JrRTvoIz36aCdo+J/Mo1A9gEN8eVPV/AL7aeZ803YdKgIitd3qw3pY4wpmT6TXNg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm64@1.97.3: - resolution: {integrity: sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==} - engines: {node: '>=14.0.0'} - cpu: [arm64] - os: [android] - sass-embedded-android-arm@1.104.1: resolution: {integrity: sha512-g/XPAJYAzSSQZFP4xCdGD0HLKB4mf24cJ/+5Nls8rMool3jNkBv9+AFO4EV0b+RxN8rLcEsMccH1CR64yIFYcQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-arm@1.97.3: - resolution: {integrity: sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==} - engines: {node: '>=14.0.0'} - cpu: [arm] - os: [android] - sass-embedded-android-riscv64@1.104.1: resolution: {integrity: sha512-ZYY8oMKQS7Q/6FBoC8w3T16O3MucDe3xv8iKym41Nht5uvLCGGpm4fC4OF5nlv7wQkJv8oactYddGG3Wd0Rqkg==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-riscv64@1.97.3: - resolution: {integrity: sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==} - engines: {node: '>=14.0.0'} - cpu: [riscv64] - os: [android] - sass-embedded-android-x64@1.104.1: resolution: {integrity: sha512-5WZKC4lSfjMIjzZdQUlYQUAV+UiatBhD8w28U7cYLLj4/dMgEPOBd4g3VMv0BwpgH2JOaycMS42dXq29W2bcDg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-android-x64@1.97.3: - resolution: {integrity: sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==} - engines: {node: '>=14.0.0'} - cpu: [x64] - os: [android] - sass-embedded-darwin-arm64@1.104.1: resolution: {integrity: sha512-oatuHzn0NsEg0d9Dcze6CuWUPvuETtMxRK4cv0eRAg7p6R0vHnv5GZRyWG6gEy3nc/ha/L+GtTasWVU82f4jrQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-arm64@1.97.3: - resolution: {integrity: sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA==} - engines: {node: '>=14.0.0'} - cpu: [arm64] - os: [darwin] - sass-embedded-darwin-x64@1.104.1: resolution: {integrity: sha512-gql/5sHGzUTVwuHfM3QLCDeb6NYwen+LLj22gz81C2NKHF/4nUtDyh4N+fURWdT7J0/EhtMOd0+FLML3sGNP7g==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-darwin-x64@1.97.3: - resolution: {integrity: sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==} - engines: {node: '>=14.0.0'} - cpu: [x64] - os: [darwin] - sass-embedded-linux-arm64@1.104.1: resolution: {integrity: sha512-sVWUKXNTu8sP3X95Df0xo/t49Ce5IseTIMqXHxhFBKVmx7gf7kBWNLK0wH5ym+4khc+B5nNJPgq4IxpelG8tMQ==} engines: {node: '>=14.0.0'} @@ -4427,13 +4279,6 @@ packages: os: [linux] libc: glibc - sass-embedded-linux-arm64@1.97.3: - resolution: {integrity: sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==} - engines: {node: '>=14.0.0'} - cpu: [arm64] - os: [linux] - libc: glibc - sass-embedded-linux-arm@1.104.1: resolution: {integrity: sha512-WfmshPJOp9vUKxgp8wZ2ncaKBbyrlW9J51M2aUQHWECjMNxTULnGOHWSmD7FViXv4zwn1Co5FIFQUipW7RCxtA==} engines: {node: '>=14.0.0'} @@ -4441,13 +4286,6 @@ packages: os: [linux] libc: glibc - sass-embedded-linux-arm@1.97.3: - resolution: {integrity: sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==} - engines: {node: '>=14.0.0'} - cpu: [arm] - os: [linux] - libc: glibc - sass-embedded-linux-musl-arm64@1.104.1: resolution: {integrity: sha512-H7b+LzZ/w8pwNExInM8HDHraqvxCMdUc5ZRGU/aSJJhZ9HjeRKBjF3GxaisVHnx7o18j1l4VQ5cLBL9tTmNzZw==} engines: {node: '>=14.0.0'} @@ -4455,13 +4293,6 @@ packages: os: [linux] libc: musl - sass-embedded-linux-musl-arm64@1.97.3: - resolution: {integrity: sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==} - engines: {node: '>=14.0.0'} - cpu: [arm64] - os: [linux] - libc: musl - sass-embedded-linux-musl-arm@1.104.1: resolution: {integrity: sha512-k0cQ/CxBzFVYiQEObg6CpF6CUglkdEhi/DZgZXQbwC27kbGGY+fatmIs/cFPJL2z1ulACwoAr86RQUFzYvw5eg==} engines: {node: '>=14.0.0'} @@ -4469,13 +4300,6 @@ packages: os: [linux] libc: musl - sass-embedded-linux-musl-arm@1.97.3: - resolution: {integrity: sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==} - engines: {node: '>=14.0.0'} - cpu: [arm] - os: [linux] - libc: musl - sass-embedded-linux-musl-riscv64@1.104.1: resolution: {integrity: sha512-9DYdn4wfrkidjxJVtEd2iCx8HKdZe+uF82GDmrYEZhgu4NjVE6a4fQyW7OLwzn3erZMHKBaObBOH1d37DnXI1A==} engines: {node: '>=14.0.0'} @@ -4483,13 +4307,6 @@ packages: os: [linux] libc: musl - sass-embedded-linux-musl-riscv64@1.97.3: - resolution: {integrity: sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==} - engines: {node: '>=14.0.0'} - cpu: [riscv64] - os: [linux] - libc: musl - sass-embedded-linux-musl-x64@1.104.1: resolution: {integrity: sha512-5xz+nAr01b6yaRdrGVgE7lNvm1lGFjwNdUvnz+teidvHCbD5oTcbhrRVrnDoLHHvq5n3Jve9TzWaaHhezaEfHA==} engines: {node: '>=14.0.0'} @@ -4497,13 +4314,6 @@ packages: os: [linux] libc: musl - sass-embedded-linux-musl-x64@1.97.3: - resolution: {integrity: sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==} - engines: {node: '>=14.0.0'} - cpu: [x64] - os: [linux] - libc: musl - sass-embedded-linux-riscv64@1.104.1: resolution: {integrity: sha512-oWryPJWTXK5adtlz/FD7wd2kS4I0CEgp6mWLUVb4oMFURMLz9bA9fO87D7lc4SV++lds7T51PU1kykCbOvNHMg==} engines: {node: '>=14.0.0'} @@ -4511,13 +4321,6 @@ packages: os: [linux] libc: glibc - sass-embedded-linux-riscv64@1.97.3: - resolution: {integrity: sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==} - engines: {node: '>=14.0.0'} - cpu: [riscv64] - os: [linux] - libc: glibc - sass-embedded-linux-x64@1.104.1: resolution: {integrity: sha512-j/sX/yKTx/wJKepC5/ISlR6kPeSuHKThf76D2za5i2VnbHg0MN19jQq3WMorowR3kRghqt6azs3awogG+e7IRA==} engines: {node: '>=14.0.0'} @@ -4525,70 +4328,32 @@ packages: os: [linux] libc: glibc - sass-embedded-linux-x64@1.97.3: - resolution: {integrity: sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==} - engines: {node: '>=14.0.0'} - cpu: [x64] - os: [linux] - libc: glibc - sass-embedded-unknown-all@1.104.1: resolution: {integrity: sha512-RaMsYZB5mRioL4qhoI7T5mmNb2OunakbPTJW2cuu3MeIULnGoxo7Wd5QKFxeQvF7sSQL57RuaQ+q9pH+/pQ9Fg==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-unknown-all@1.97.3: - resolution: {integrity: sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==} - os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.104.1: resolution: {integrity: sha512-fS6DL7lcnTPclqGLF4xRt8egZlFY9cU23d57ufbWqaUbCFu9JTu0+8qB4jMxfx1la6byi3GoSTfbnNOLfWv8sw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-arm64@1.97.3: - resolution: {integrity: sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==} - engines: {node: '>=14.0.0'} - cpu: [arm64] - os: [win32] - sass-embedded-win32-x64@1.104.1: resolution: {integrity: sha512-/c4N2A93Ckms6DuRFVftKYISuI1tyC8gmlVJ8WaWFzgCBN/Swq2rARJIBUNeuxlmxjmYXfd3+2f/u887K63wbA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded-win32-x64@1.97.3: - resolution: {integrity: sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==} - engines: {node: '>=14.0.0'} - cpu: [x64] - os: [win32] - sass-embedded@1.104.1: resolution: {integrity: sha512-7LxbtPJ1MWKVbhzOnGx1sHTkHTcHVJ9hbgVsU29p/8gY1VeXt19fcJFr5xsx2f6zfrrR3FeOcYMt0uYDLAxpgw==} engines: {node: '>=20.19.0'} hasBin: true - sass-embedded@1.97.3: - resolution: {integrity: sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==} - engines: {node: '>=16.0.0'} - hasBin: true - - sass@1.101.0: - resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==} - engines: {node: '>=20.19.0'} - hasBin: true - sass@1.104.1: resolution: {integrity: sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==} engines: {node: '>=20.19.0'} hasBin: true - sass@1.97.3: - resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} - engines: {node: '>=14.0.0'} - hasBin: true - sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -5400,7 +5165,7 @@ snapshots: '@angular-devkit/core@22.2.1(chokidar@5.0.0)': dependencies: ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats: 3.0.1 jsonc-parser: 3.3.1 picomatch: 4.0.7 rxjs: 7.8.2 @@ -6439,9 +6204,6 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.150.0': optional: true - '@oxc-project/types@0.142.0': - optional: true - '@oxc-project/types@0.149.0': {} '@oxc-project/types@0.150.0': {} @@ -6524,126 +6286,84 @@ snapshots: '@rolldown/binding-android-arm64@1.2.12': optional: true - '@rolldown/binding-android-arm64@1.2.2': - optional: true - '@rolldown/binding-android-arm64@1.2.8': optional: true '@rolldown/binding-darwin-arm64@1.2.12': optional: true - '@rolldown/binding-darwin-arm64@1.2.2': - optional: true - '@rolldown/binding-darwin-arm64@1.2.8': optional: true '@rolldown/binding-darwin-x64@1.2.12': optional: true - '@rolldown/binding-darwin-x64@1.2.2': - optional: true - '@rolldown/binding-darwin-x64@1.2.8': optional: true '@rolldown/binding-freebsd-x64@1.2.12': optional: true - '@rolldown/binding-freebsd-x64@1.2.2': - optional: true - '@rolldown/binding-freebsd-x64@1.2.8': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.2.12': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.2': - optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': optional: true '@rolldown/binding-linux-arm64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.2': - optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': optional: true '@rolldown/binding-linux-arm64-musl@1.2.12': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.2': - optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': optional: true '@rolldown/binding-linux-ppc64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.2': - optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': optional: true '@rolldown/binding-linux-s390x-gnu@1.2.12': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.2': - optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': optional: true '@rolldown/binding-linux-x64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.2': - optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': optional: true '@rolldown/binding-linux-x64-musl@1.2.12': optional: true - '@rolldown/binding-linux-x64-musl@1.2.2': - optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': optional: true '@rolldown/binding-openharmony-arm64@1.2.12': optional: true - '@rolldown/binding-openharmony-arm64@1.2.2': - optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': optional: true '@rolldown/binding-win32-arm64-msvc@1.2.12': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.2': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': optional: true '@rolldown/binding-win32-x64-msvc@1.2.12': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.2': - optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': optional: true @@ -6796,7 +6516,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) - ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats: 3.0.1 fs-extra: 11.3.6 import-lazy: 4.0.0 jju: 1.4.0 @@ -7282,13 +7002,13 @@ snapshots: ajv: 8.20.0 optional: true - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@2.1.1: + dependencies: ajv: 8.20.0 optional: true - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@3.0.1: + dependencies: ajv: 8.20.0 ajv-keywords@5.1.0(ajv@8.20.0): @@ -7538,11 +7258,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - optional: true - chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -7577,9 +7292,6 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - colorjs.io@0.5.2: - optional: true - colorjs.io@0.7.1: {} combined-stream@1.0.8: @@ -9160,9 +8872,6 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@4.1.2: - optional: true - readdirp@5.0.0: {} reflect-metadata@0.2.2: {} @@ -9234,27 +8943,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.12 '@rolldown/binding-win32-x64-msvc': 1.2.12 - rolldown@1.2.2: - dependencies: - '@oxc-project/types': 0.142.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.2 - '@rolldown/binding-darwin-arm64': 1.2.2 - '@rolldown/binding-darwin-x64': 1.2.2 - '@rolldown/binding-freebsd-x64': 1.2.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 - '@rolldown/binding-linux-arm64-gnu': 1.2.2 - '@rolldown/binding-linux-arm64-musl': 1.2.2 - '@rolldown/binding-linux-ppc64-gnu': 1.2.2 - '@rolldown/binding-linux-s390x-gnu': 1.2.2 - '@rolldown/binding-linux-x64-gnu': 1.2.2 - '@rolldown/binding-linux-x64-musl': 1.2.2 - '@rolldown/binding-openharmony-arm64': 1.2.2 - '@rolldown/binding-win32-arm64-msvc': 1.2.2 - '@rolldown/binding-win32-x64-msvc': 1.2.2 - optional: true - rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -9338,117 +9026,59 @@ snapshots: sass: 1.104.1 optional: true - sass-embedded-all-unknown@1.97.3: - dependencies: - sass: 1.97.3 - optional: true - sass-embedded-android-arm64@1.104.1: optional: true - sass-embedded-android-arm64@1.97.3: - optional: true - sass-embedded-android-arm@1.104.1: optional: true - sass-embedded-android-arm@1.97.3: - optional: true - sass-embedded-android-riscv64@1.104.1: optional: true - sass-embedded-android-riscv64@1.97.3: - optional: true - sass-embedded-android-x64@1.104.1: optional: true - sass-embedded-android-x64@1.97.3: - optional: true - sass-embedded-darwin-arm64@1.104.1: optional: true - sass-embedded-darwin-arm64@1.97.3: - optional: true - sass-embedded-darwin-x64@1.104.1: optional: true - sass-embedded-darwin-x64@1.97.3: - optional: true - sass-embedded-linux-arm64@1.104.1: optional: true - sass-embedded-linux-arm64@1.97.3: - optional: true - sass-embedded-linux-arm@1.104.1: optional: true - sass-embedded-linux-arm@1.97.3: - optional: true - sass-embedded-linux-musl-arm64@1.104.1: optional: true - sass-embedded-linux-musl-arm64@1.97.3: - optional: true - sass-embedded-linux-musl-arm@1.104.1: optional: true - sass-embedded-linux-musl-arm@1.97.3: - optional: true - sass-embedded-linux-musl-riscv64@1.104.1: optional: true - sass-embedded-linux-musl-riscv64@1.97.3: - optional: true - sass-embedded-linux-musl-x64@1.104.1: optional: true - sass-embedded-linux-musl-x64@1.97.3: - optional: true - sass-embedded-linux-riscv64@1.104.1: optional: true - sass-embedded-linux-riscv64@1.97.3: - optional: true - sass-embedded-linux-x64@1.104.1: optional: true - sass-embedded-linux-x64@1.97.3: - optional: true - sass-embedded-unknown-all@1.104.1: dependencies: sass: 1.104.1 optional: true - sass-embedded-unknown-all@1.97.3: - dependencies: - sass: 1.97.3 - optional: true - sass-embedded-win32-arm64@1.104.1: optional: true - sass-embedded-win32-arm64@1.97.3: - optional: true - sass-embedded-win32-x64@1.104.1: optional: true - sass-embedded-win32-x64@1.97.3: - optional: true - sass-embedded@1.104.1: dependencies: '@bufbuild/protobuf': 2.13.0 @@ -9478,45 +9108,6 @@ snapshots: sass-embedded-win32-arm64: 1.104.1 sass-embedded-win32-x64: 1.104.1 - sass-embedded@1.97.3: - dependencies: - '@bufbuild/protobuf': 2.13.0 - colorjs.io: 0.5.2 - immutable: 5.1.9 - rxjs: 7.8.2 - supports-color: 8.1.1 - sync-child-process: 1.0.2 - varint: 6.0.0 - optionalDependencies: - sass-embedded-all-unknown: 1.97.3 - sass-embedded-android-arm: 1.97.3 - sass-embedded-android-arm64: 1.97.3 - sass-embedded-android-riscv64: 1.97.3 - sass-embedded-android-x64: 1.97.3 - sass-embedded-darwin-arm64: 1.97.3 - sass-embedded-darwin-x64: 1.97.3 - sass-embedded-linux-arm: 1.97.3 - sass-embedded-linux-arm64: 1.97.3 - sass-embedded-linux-musl-arm: 1.97.3 - sass-embedded-linux-musl-arm64: 1.97.3 - sass-embedded-linux-musl-riscv64: 1.97.3 - sass-embedded-linux-musl-x64: 1.97.3 - sass-embedded-linux-riscv64: 1.97.3 - sass-embedded-linux-x64: 1.97.3 - sass-embedded-unknown-all: 1.97.3 - sass-embedded-win32-arm64: 1.97.3 - sass-embedded-win32-x64: 1.97.3 - optional: true - - sass@1.101.0: - dependencies: - chokidar: 5.0.0 - immutable: 5.1.9 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.6.0 - optional: true - sass@1.104.1: dependencies: chokidar: 5.0.0 @@ -9525,15 +9116,6 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.6.0 - sass@1.97.3: - dependencies: - chokidar: 4.0.3 - immutable: 5.1.9 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.6.0 - optional: true - sax@1.6.1: optional: true @@ -9545,7 +9127,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats: 2.1.1 ajv-keywords: 5.1.0(ajv@8.20.0) optional: true @@ -9936,7 +9518,7 @@ snapshots: universalify@2.0.1: {} - unplugin-dts@1.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)): + unplugin-dts@1.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.60.4) '@volar/typescript': 2.4.28(typescript@6.0.2) @@ -9952,7 +9534,7 @@ snapshots: '@microsoft/api-extractor': 7.56.0(@types/node@26.6.4) '@rspack/core': 1.6.8(@swc/helpers@0.5.18) esbuild: 0.28.2 - rolldown: 1.2.2 + rolldown: 1.2.12 rollup: 4.60.4 vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0) webpack: 5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28) @@ -9982,14 +9564,14 @@ snapshots: verkit@0.3.2: {} - vite-live-preview@0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)): + vite-live-preview@0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0) ws: 8.21.1 transitivePeerDependencies: - bufferutil @@ -10010,9 +9592,9 @@ snapshots: optionator: 0.9.4 typescript: 6.0.2 - vite-plugin-dts@5.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)): + vite-plugin-dts@5.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)): dependencies: - unplugin-dts: 1.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)) + unplugin-dts: 1.1.2(@microsoft/api-extractor@7.56.0(@types/node@26.6.4))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)) optionalDependencies: '@microsoft/api-extractor': 7.56.0(@types/node@26.6.4) rollup: 4.60.4 @@ -10072,25 +9654,6 @@ snapshots: tsx: 4.23.15 yaml: 2.9.0 - vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.12 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.6.4 - esbuild: 0.28.2 - fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.6.4 - sass: 1.101.0 - sass-embedded: 1.97.3 - terser: 5.46.2 - tsx: 4.23.15 - yaml: 2.9.0 - vitest@5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 diff --git a/scripts/changelog.py b/scripts/changelog.py index e964bacc18..280ffa1f85 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -8,6 +8,7 @@ flow memory (`mem:workflow/updating-changelog`) stays free of inline programs. Subcommands: check-merged Verify that every given PR is merged (warns otherwise) boom Generate the :boom: section from `breaking change` issues + security Generate the :lock: section from advisories fixed in a version cross-ref Compare merged milestone PRs against the changelog section report Generate the anomaly report (CHANGES-ISSUES.md) @@ -16,6 +17,8 @@ Usage: cat prs.txt | python3 scripts/changelog.py check-merged --stdin python3 scripts/changelog.py boom "2.19.0" python3 scripts/changelog.py boom "2.19.0" --write + python3 scripts/changelog.py security "2.19.1" + python3 scripts/changelog.py security "2.19.1" --write python3 scripts/changelog.py cross-ref "2.16.0" python3 scripts/changelog.py report "2.16.0" python3 scripts/changelog.py report "2.16.0" --changes CHANGES.md --output CHANGES-ISSUES.md @@ -38,9 +41,15 @@ REPO = "penpot/penpot" GH_PY = Path(__file__).resolve().parent / "gh.py" BOOM_HEADING = "### :boom: Breaking changes & Deprecations" +SECURITY_HEADING = "### :lock: Security fixes" BREAKING_LABEL = "breaking change" EXCLUDED_LABELS = {"release blocker", "no changelog"} +# PR label carried by the security fixes that reference their advisory in the +# PR body, and the GHSA id pattern used to find that reference. +SECURITY_PR_LABEL = "pr pt" +GHSA_ID_RE = re.compile(r"GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}", re.IGNORECASE) + # Marker line of the issue comment that carries the changelog text of a # breaking change. Everything after the marker line is the entry content. BREAKING_NOTE_RE = re.compile(r"(?im)^#{1,3}[ \t]+Breaking change note[ \t]*$") @@ -269,8 +278,8 @@ def version_section_bounds(content: str, milestone: str) -> tuple[int, int] | No return start, end -def boom_body(entries: list[str]) -> str: - """Return the body text that follows the ``:boom:`` heading line.""" +def entry_list_body(entries: list[str]) -> str: + """Return the body text that follows a generated subsection heading.""" if entries: return "\n\n" + "\n\n".join(entries) + "\n\n" return "\n\n" @@ -292,12 +301,12 @@ def replace_boom_section(content: str, milestone: str, entries: list[str]) -> st body_start = start + heading.end() next_heading = re.search(r"(?m)^### ", content[body_start:end]) body_end = body_start + next_heading.start() if next_heading else end - return content[:body_start] + boom_body(entries) + content[body_end:] + return content[:body_start] + entry_list_body(entries) + content[body_end:] header = re.match(r"(?m)^## [^\n]*\n", section) if header is None: raise ValueError(f'version "## {milestone}" header has no trailing newline') insert_at = start + header.end() - block = "\n" + BOOM_HEADING + boom_body(entries)[:-1] + block = "\n" + BOOM_HEADING + entry_list_body(entries)[:-1] return content[:insert_at] + block + content[insert_at:] @@ -373,7 +382,7 @@ def cmd_boom(args: argparse.Namespace) -> None: render_boom_entry(issue, note, [pr["number"] for pr in merged], author) ) - block = BOOM_HEADING + boom_body(entries) + block = BOOM_HEADING + entry_list_body(entries) print(block.rstrip("\n")) if not labelled: @@ -398,6 +407,177 @@ def cmd_boom(args: argparse.Namespace) -> None: print(f"{args.changes}: :boom: section updated", file=sys.stderr) +# ───────────────────────────────────────────── +# Subcommand: security +# ───────────────────────────────────────────── + + +def imperative_description(summary: str) -> str: + """Return a changelog description built from an advisory summary. + + Advisory summaries are declarative ("Missing authorization in ... lets + ..."), so the description prefixes ``Fix`` and decapitalizes the first + word unless it starts with an acronym (``SVG``, ``CVE-...``). The wording + may still be polished in the flow; re-running ``security --write`` + rewrites the entry from the advisory summary. + """ + summary = strip_title_prefix((summary or "").strip()) + if not summary: + return "" + first_word = summary.split(maxsplit=1)[0] + if len(first_word) > 1 and first_word[1:].islower(): + summary = summary[0].lower() + summary[1:] + return f"Fix {summary}" + + +def render_security_entry(advisory: dict, pr_numbers: list[int]) -> str: + """Render one ``:lock:`` entry: description, advisory link and PRs.""" + ghsa_id = advisory["ghsa_id"] + url = advisory.get("url") or ( + f"https://github.com/{REPO}/security/advisories/{ghsa_id}" + ) + description = imperative_description(advisory.get("summary", "")) or ( + "Fix a security issue" + ) + entry = f"- {description} ({url})" + if pr_numbers: + entry += " (PR: " + ", ".join(pr_link(n) for n in pr_numbers) + ")" + return entry + + +def fetch_security_advisories(milestone: str) -> list[dict]: + """Fetch the advisories that declare ``milestone`` as a patched version.""" + return json.loads(run_gh_py("advisories", "--patched-version", milestone)) + + +def fetch_security_prs(milestone: str) -> dict[str, list[int]]: + """Map each GHSA id to the merged ``pr pt`` PRs in a milestone referencing it.""" + by_ghsa: dict[str, list[int]] = {} + for pr in fetch_milestone_prs(milestone, "all"): + if pr.get("state") != "MERGED": + continue + if SECURITY_PR_LABEL not in pr.get("labels", []): + continue + for ghsa_id in GHSA_ID_RE.findall(pr.get("body") or ""): + by_ghsa.setdefault(ghsa_id.upper(), []).append(pr["number"]) + return {ghsa_id: sorted(set(numbers)) for ghsa_id, numbers in by_ghsa.items()} + + +def subsection_end( + content: str, section: str, start: int, end: int, heading_re: str +) -> int | None: + """Return the offset where the body of a ``###`` subsection ends. + + ``heading_re`` is a prefix of the heading line (e.g. ``### :rocket:``), so + the heading text that follows it does not matter. + """ + match = re.search(rf"(?m)^{heading_re}", section) + if not match: + return None + body_start = start + match.end() + next_heading = re.search(r"(?m)^### ", content[body_start:end]) + return body_start + next_heading.start() if next_heading else end + + +def replace_security_section( + content: str, milestone: str, entries: list[str] +) -> str: + """Return ``content`` with its ``:lock:`` subsection replaced or added. + + The subsection follows ``:rocket:`` (or ``:boom:`` when there is no + ``:rocket:``, or the version header when neither exists), which keeps the + layout order. An empty ``entries`` list removes the subsection: the + heading only exists when the version has security fixes to list. + """ + bounds = version_section_bounds(content, milestone) + if bounds is None: + raise ValueError(f'no "## {milestone}" section found') + start, end = bounds + section = content[start:end] + + heading = re.search(rf"(?m)^{re.escape(SECURITY_HEADING)}[ \t]*$", section) + if heading: + heading_start = start + heading.start() + body_start = start + heading.end() + next_heading = re.search(r"(?m)^### ", content[body_start:end]) + body_end = body_start + next_heading.start() if next_heading else end + if not entries: + return content[:heading_start] + content[body_end:] + return ( + content[:heading_start] + + SECURITY_HEADING + + entry_list_body(entries) + + content[body_end:] + ) + + if not entries: + return content + + anchor = subsection_end(content, section, start, end, r"### :rocket:") + if anchor is None: + anchor = subsection_end(content, section, start, end, r"### :boom:") + if anchor is None: + header = re.match(r"(?m)^## [^\n]*\n", section) + if header is None: + raise ValueError(f'version "## {milestone}" header has no trailing newline') + insert_at = start + header.end() + block = "\n" + SECURITY_HEADING + entry_list_body(entries)[:-1] + return content[:insert_at] + block + content[insert_at:] + + block = SECURITY_HEADING + entry_list_body(entries) + return content[:anchor] + block + content[anchor:] + + +def cmd_security(args: argparse.Namespace) -> None: + """Generate the ``:lock:`` section from advisories fixed in the version.""" + milestone = args.milestone + advisories = fetch_security_advisories(milestone) + prs_by_ghsa = fetch_security_prs(milestone) + + matched = { + advisory["ghsa_id"].upper() + for advisory in advisories + if advisory.get("ghsa_id") + } + for ghsa_id in sorted(set(prs_by_ghsa) - matched): + numbers = ", ".join(f"#{n}" for n in prs_by_ghsa[ghsa_id]) + print( + f"WARNING: PR {numbers} references {ghsa_id}, which declares no patched " + f'version "{milestone}"; it will not be listed under :lock:', + file=sys.stderr, + ) + + entries = [ + render_security_entry( + advisory, prs_by_ghsa.get(advisory["ghsa_id"].upper(), []) + ) + for advisory in sorted(advisories, key=lambda advisory: advisory["ghsa_id"]) + ] + + if entries: + print((SECURITY_HEADING + entry_list_body(entries)).rstrip("\n")) + else: + print( + f"No advisories declare patched version {milestone}", + file=sys.stderr, + ) + + if args.write: + with open(args.changes) as f: + content = f.read() + try: + updated = replace_security_section(content, milestone, entries) + except ValueError as err: + print(f"ERROR: {err}", file=sys.stderr) + sys.exit(1) + if updated == content: + print(f"{args.changes}: :lock: section already up to date", file=sys.stderr) + else: + with open(args.changes, "w") as f: + f.write(updated) + print(f"{args.changes}: :lock: section updated", file=sys.stderr) + + # ───────────────────────────────────────────── # Subcommand: cross-ref # ───────────────────────────────────────────── @@ -901,6 +1081,16 @@ def main() -> None: help="Replace the :boom: section in CHANGES.md in place") p_boom.set_defaults(func=cmd_boom) + p_security = sub.add_parser( + "security", help="Generate the :lock: section from advisories fixed in a version" + ) + p_security.add_argument("milestone", help="Milestone title (e.g. '2.19.1')") + p_security.add_argument("--changes", default="CHANGES.md", + help="Path to the changelog file (default: CHANGES.md)") + p_security.add_argument("--write", action="store_true", + help="Replace the :lock: section in CHANGES.md in place") + p_security.set_defaults(func=cmd_security) + p_cross = sub.add_parser( "cross-ref", help="Compare merged milestone PRs against the changelog section", diff --git a/scripts/gh.py b/scripts/gh.py index 2ec3eb8dc3..eebd5155fd 100755 --- a/scripts/gh.py +++ b/scripts/gh.py @@ -31,6 +31,7 @@ Usage: python3 scripts/gh.py comments 11235 11236 python3 scripts/gh.py advisories (list all advisories) python3 scripts/gh.py advisories --severity critical (filter by severity) + python3 scripts/gh.py advisories --patched-version 2.19.1 (filter by patched version) python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail) python3 scripts/gh.py link-issue 11235 11243 @@ -980,6 +981,22 @@ def format_advisory_summary(adv: dict) -> dict: } +def advisory_patched_versions(adv: dict) -> list[str]: + """Return every patched version declared by an advisory's vulnerabilities.""" + versions: list[str] = [] + for vuln in adv.get("vulnerabilities", []): + for token in re.split(r"[,;]", vuln.get("patched_versions") or ""): + token = token.strip() + if token and token not in versions: + versions.append(token) + return versions + + +def advisory_fixed_in_version(adv: dict, version: str) -> bool: + """Return True when the advisory declares ``version`` among its patched ones.""" + return version in advisory_patched_versions(adv) + + def format_advisory_detail(adv: dict) -> dict: """Extract full detail view of an advisory.""" summary = format_advisory_summary(adv) @@ -1020,6 +1037,16 @@ def cmd_advisories(args: argparse.Namespace) -> None: advisories = fetch_advisories() print(f"Fetched {len(advisories)} advisories", file=sys.stderr) + if args.patched_version: + version = args.patched_version.strip() + advisories = [ + adv for adv in advisories if advisory_fixed_in_version(adv, version) + ] + print( + f"After patched-version filter ({version}): {len(advisories)} advisories", + file=sys.stderr, + ) + results = [format_advisory_summary(adv) for adv in advisories] # Apply filters @@ -1160,6 +1187,10 @@ def main() -> None: "--state", choices=["triage", "draft", "published", "closed", "withdrawn"], help="Filter by advisory state" ) + p_adv.add_argument( + "--patched-version", type=str, + help="Filter by patched version declared in the advisory (e.g. '2.19.1')" + ) p_adv.set_defaults(func=cmd_advisories) args = parser.parse_args() diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 5d6a19946e..e27d3de1d2 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -379,5 +379,283 @@ class ReportTests(unittest.TestCase): self.assertIn("**:boom: entry without breaking change label:** 1", report) +ADVISORY = { + "ghsa_id": "GHSA-j4vc-cmf6-jhr7", + "url": "https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7", + "summary": ( + "Missing authorization in mark-all-threads-as-read lets a user update " + "inaccessible threads" + ), +} + +SECURITY_CHANGES = """# CHANGELOG + +## 2.19.1 + +### :bug: Bugs fixed + +- Fix something [#1](https://github.com/penpot/penpot/issues/1) +""" + +SECURITY_CHANGES_WITH_ROCKET = """# CHANGELOG + +## 2.19.1 + +### :rocket: Epics and highlights + +- Add highlights [#1](https://github.com/penpot/penpot/issues/1) + +### :bug: Bugs fixed + +- Fix something [#2](https://github.com/penpot/penpot/issues/2) +""" + +SECURITY_CHANGES_WITH_BOOM = """# CHANGELOG + +## 2.19.1 + +### :boom: Breaking changes & Deprecations + +#### Old entry + +[#1](https://github.com/penpot/penpot/issues/1) + +### :bug: Bugs fixed + +- Fix something [#2](https://github.com/penpot/penpot/issues/2) +""" + + +class ImperativeDescriptionTests(unittest.TestCase): + def test_prefixes_fix_and_decapitalizes(self): + self.assertEqual( + ch.imperative_description("Missing authorization in foo"), + "Fix missing authorization in foo", + ) + + def test_keeps_acronyms(self): + self.assertEqual( + ch.imperative_description("SVG sanitizer escapes nothing"), + "Fix SVG sanitizer escapes nothing", + ) + self.assertEqual( + ch.imperative_description("CVE-2026-105694: sanitizer escapes nothing"), + "Fix CVE-2026-105694: sanitizer escapes nothing", + ) + + def test_strips_emoji_prefix_and_handles_empty(self): + self.assertEqual( + ch.imperative_description(":bug: Missing check"), "Fix missing check" + ) + self.assertEqual(ch.imperative_description(""), "") + + +class RenderSecurityEntryTests(unittest.TestCase): + def test_entry_has_description_advisory_and_prs(self): + self.assertEqual( + ch.render_security_entry(ADVISORY, [8, 9]), + "- Fix missing authorization in mark-all-threads-as-read lets a user " + "update inaccessible threads " + "(https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7)" + " (PR: [#8](https://github.com/penpot/penpot/pull/8)," + " [#9](https://github.com/penpot/penpot/pull/9))", + ) + + def test_entry_without_prs_links_only_the_advisory(self): + entry = ch.render_security_entry(ADVISORY, []) + self.assertNotIn("PR:", entry) + self.assertIn("security/advisories/GHSA-j4vc-cmf6-jhr7", entry) + + def test_entry_builds_the_url_when_the_advisory_lacks_it(self): + advisory = dict(ADVISORY) + del advisory["url"] + self.assertIn( + "(https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7)", + ch.render_security_entry(advisory, []), + ) + + +class FetchSecurityPrsTests(unittest.TestCase): + def test_maps_merged_pr_pt_prs_by_ghsa(self): + prs = [ + { + "number": 10, + "state": "MERGED", + "labels": ["internal", "pr pt"], + "body": "Relates to [GHSA-4mmc-j8rh-74pf]" + "(https://github.com/penpot/penpot/security/advisories/GHSA-4mmc-j8rh-74pf)", + }, + { + "number": 11, + "state": "CLOSED", + "labels": ["pr pt"], + "body": "Closes GHSA-j4vc-cmf6-jhr7", + }, + { + "number": 12, + "state": "MERGED", + "labels": ["bug"], + "body": "GHSA-qwhj-rchj-58mg", + }, + { + "number": 13, + "state": "MERGED", + "labels": ["pr pt"], + "body": "No advisory here", + }, + ] + with patch.object(ch, "fetch_milestone_prs", return_value=prs): + result = ch.fetch_security_prs("2.19.1") + self.assertEqual(result, {"GHSA-4MMC-J8RH-74PF": [10]}) + + +class ReplaceSecuritySectionTests(unittest.TestCase): + ENTRY = ( + "- Fix missing check (https://example.test/ghsa)" + " (PR: [#8](https://example.test/pr/8))" + ) + + def test_inserts_after_rocket(self): + updated = ch.replace_security_section( + SECURITY_CHANGES_WITH_ROCKET, "2.19.1", [self.ENTRY] + ) + self.assertIn( + "### :rocket: Epics and highlights\n\n" + "- Add highlights [#1](https://github.com/penpot/penpot/issues/1)\n\n" + "### :lock: Security fixes\n\n" + f"{self.ENTRY}\n\n" + "### :bug: Bugs fixed", + updated, + ) + + def test_inserts_after_boom_when_there_is_no_rocket(self): + updated = ch.replace_security_section( + SECURITY_CHANGES_WITH_BOOM, "2.19.1", [self.ENTRY] + ) + self.assertIn( + "#### Old entry\n\n[#1](https://github.com/penpot/penpot/issues/1)\n\n" + "### :lock: Security fixes\n\n" + f"{self.ENTRY}\n\n" + "### :bug: Bugs fixed", + updated, + ) + + def test_inserts_after_the_header_when_no_anchor_exists(self): + updated = ch.replace_security_section( + SECURITY_CHANGES, "2.19.1", [self.ENTRY] + ) + self.assertIn( + "## 2.19.1\n\n" + "### :lock: Security fixes\n\n" + f"{self.ENTRY}\n\n" + "### :bug: Bugs fixed", + updated, + ) + + def test_replacement_is_idempotent(self): + once = ch.replace_security_section( + SECURITY_CHANGES, "2.19.1", [self.ENTRY] + ) + twice = ch.replace_security_section(once, "2.19.1", [self.ENTRY]) + self.assertEqual(once, twice) + self.assertEqual(once.count("### :lock: Security fixes"), 1) + + def test_empty_entries_remove_the_subsection(self): + once = ch.replace_security_section( + SECURITY_CHANGES, "2.19.1", [self.ENTRY] + ) + removed = ch.replace_security_section(once, "2.19.1", []) + self.assertNotIn(":lock:", removed) + self.assertEqual(removed, SECURITY_CHANGES) + + def test_empty_entries_leave_a_section_without_heading_untouched(self): + self.assertEqual( + ch.replace_security_section(SECURITY_CHANGES, "2.19.1", []), + SECURITY_CHANGES, + ) + + def test_unknown_version_raises(self): + with self.assertRaises(ValueError): + ch.replace_security_section(SECURITY_CHANGES, "9.9.9", [self.ENTRY]) + + +class CmdSecurityTests(unittest.TestCase): + ADVISORIES = [ADVISORY] + PRS = [ + { + "number": 8, + "state": "MERGED", + "labels": ["pr pt"], + "body": "Closes GHSA-j4vc-cmf6-jhr7", + } + ] + + def setUp(self): + self.calls = [] + + def fake_run_gh_py(*args, input_text=None): + self.calls.append(args) + if args[0] == "advisories": + return json.dumps(self.ADVISORIES) + if args[0] == "prs": + return json.dumps(self.PRS) + raise AssertionError(f"unexpected gh.py call: {args}") + + patcher = patch.object(ch, "run_gh_py", side_effect=fake_run_gh_py) + patcher.start() + self.addCleanup(patcher.stop) + + def run_security(self, changes=SECURITY_CHANGES, write=False): + args = argparse.Namespace(milestone="2.19.1", changes=changes, write=write) + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + ch.cmd_security(args) + return out.getvalue(), err.getvalue() + + def test_prints_the_section_with_the_linked_pr(self): + out, _ = self.run_security() + self.assertIn("### :lock: Security fixes", out) + self.assertIn("security/advisories/GHSA-j4vc-cmf6-jhr7", out) + self.assertIn("(PR: [#8](https://github.com/penpot/penpot/pull/8))", out) + self.assertEqual( + self.calls[0], ("advisories", "--patched-version", "2.19.1") + ) + + def test_no_advisories_prints_no_section(self): + self.ADVISORIES = [] + self.PRS = [] + out, err = self.run_security() + self.assertEqual(out, "") + self.assertIn("No advisories", err) + + def test_writes_the_section_in_place(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "CHANGES.md" + path.write_text(SECURITY_CHANGES) + _, err = self.run_security(changes=str(path), write=True) + updated = path.read_text() + self.assertIn("### :lock: Security fixes", updated) + self.assertIn("(PR: [#8](https://github.com/penpot/penpot/pull/8))", updated) + self.assertIn(":lock: section updated", err) + + def test_no_advisories_leaves_the_file_untouched(self): + self.ADVISORIES = [] + self.PRS = [] + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "CHANGES.md" + path.write_text(SECURITY_CHANGES) + _, err = self.run_security(changes=str(path), write=True) + updated = path.read_text() + self.assertEqual(updated, SECURITY_CHANGES) + self.assertIn(":lock: section already up to date", err) + + def test_warns_when_a_pr_references_an_unmatched_advisory(self): + self.ADVISORIES = [] + _, err = self.run_security() + self.assertIn("GHSA-J4VC-CMF6-JHR7", err) + self.assertIn("#8", err) + self.assertIn("will not be listed", err) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/scripts/test_gh.py b/scripts/test_gh.py index 3255e8b495..b703b389eb 100644 --- a/scripts/test_gh.py +++ b/scripts/test_gh.py @@ -156,5 +156,69 @@ class CommentsTests(unittest.TestCase): self.assertEqual(error.exception.code, 1) +class AdvisoriesTests(unittest.TestCase): + def advisory(self, patched, ghsa="GHSA-aaaa-bbbb-cccc"): + return { + "ghsa_id": ghsa, + "severity": "medium", + "state": "draft", + "summary": "Something", + "html_url": f"https://github.com/penpot/penpot/security/advisories/{ghsa}", + "vulnerabilities": [ + { + "package": {"name": "penpot"}, + "vulnerable_version_range": "<2.19.1", + "patched_versions": patched, + } + ], + } + + def test_fixed_in_version_matches_exact_tokens(self): + self.assertTrue(gh.advisory_fixed_in_version(self.advisory("2.19.1"), "2.19.1")) + self.assertFalse( + gh.advisory_fixed_in_version(self.advisory("2.19.10"), "2.19.1") + ) + self.assertFalse(gh.advisory_fixed_in_version(self.advisory(""), "2.19.1")) + self.assertFalse( + gh.advisory_fixed_in_version({"vulnerabilities": []}, "2.19.1") + ) + + def test_fixed_in_version_accepts_several_tokens(self): + self.assertTrue( + gh.advisory_fixed_in_version(self.advisory("2.19.0, 2.19.1"), "2.19.1") + ) + self.assertTrue( + gh.advisory_fixed_in_version(self.advisory(" 2.19.1 "), "2.19.1") + ) + + @patch.object(gh, "fetch_advisories") + def test_cmd_advisories_filters_by_patched_version(self, fetch_advisories): + matching = self.advisory("2.19.1", "GHSA-j4vc-cmf6-jhr7") + other = self.advisory("2.19.0", "GHSA-4mmc-j8rh-74pf") + fetch_advisories.return_value = [matching, other] + args = types.SimpleNamespace( + ghsa_id=None, + severity=None, + state=None, + patched_version="2.19.1", + ) + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr( + io.StringIO() + ): + gh.cmd_advisories(args) + + results = json.loads(stdout.getvalue()) + self.assertEqual([r["ghsa_id"] for r in results], ["GHSA-j4vc-cmf6-jhr7"]) + # The list output keeps the summary schema: raw vulnerabilities stay + # internal to the filter. + self.assertNotIn("vulnerabilities", results[0]) + self.assertEqual( + results[0]["url"], + "https://github.com/penpot/penpot/security/advisories/GHSA-j4vc-cmf6-jhr7", + ) + + if __name__ == "__main__": unittest.main(verbosity=2)