mirror of
https://github.com/penpot/penpot.git
synced 2026-09-10 12:49:07 -04:00
Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8f0bb125 | ||
|
|
ea8a7dd3d8 | ||
|
|
65b578a549 | ||
|
|
bf2fcba3be | ||
|
|
d6e82f44a6 | ||
|
|
e67ebacef2 | ||
|
|
91860f6916 | ||
|
|
831953c41e | ||
|
|
8a3540336b | ||
|
|
6f4a68100d | ||
|
|
62fdc02c0f | ||
|
|
c1bd3cb9f0 | ||
|
|
011feeaf71 | ||
|
|
e96a75d366 | ||
|
|
3b1290c6a2 | ||
|
|
3f1c88fca2 | ||
|
|
5c474939ac | ||
|
|
f68c266380 | ||
|
|
05ea09f2ba | ||
|
|
e4d1816117 | ||
|
|
93b18fb1c1 | ||
|
|
8fac9cf178 | ||
|
|
61ed2a203f | ||
|
|
3b49ff532f | ||
|
|
fbfef42145 | ||
|
|
f695553469 | ||
|
|
59c8a690da | ||
|
|
b115b75d83 | ||
|
|
3d1393e8fc | ||
|
|
afe62e6592 | ||
|
|
99e6d4f1ad | ||
|
|
f91ea6efc4 | ||
|
|
32d313b0c8 | ||
|
|
2e5157e6aa | ||
|
|
fb6ece7a7e | ||
|
|
18e641d79a | ||
|
|
efa2518fe8 | ||
|
|
af5b767933 | ||
|
|
937b3fc65f | ||
|
|
5f1e151e84 | ||
|
|
ff63668c1e | ||
|
|
7b135b80b2 | ||
|
|
5b97fb9408 | ||
|
|
269aa36f82 | ||
|
|
acc078064b | ||
|
|
1f12561427 | ||
|
|
77bf3ea419 | ||
|
|
5f6169e1d3 | ||
|
|
c52778d6f6 | ||
|
|
9462543fb5 | ||
|
|
5c10ea5bd6 | ||
|
|
cd98a88c4d | ||
|
|
1dfa2cd9f2 | ||
|
|
f0680cf5f8 | ||
|
|
7ebd7cc0d5 | ||
|
|
2100ed29ea | ||
|
|
7e1139b906 | ||
|
|
960209f1fa | ||
|
|
9289e676e1 | ||
|
|
a8e0b3c1f8 | ||
|
|
c8691d0cde | ||
|
|
00e0492bb5 | ||
|
|
d82038a570 | ||
|
|
5452eb5c8a | ||
|
|
0533be100d | ||
|
|
df48c834e3 | ||
|
|
86aebd3429 | ||
|
|
7f46c8c80e | ||
|
|
fdcc4666e3 | ||
|
|
064f200774 | ||
|
|
214dbd0c8b | ||
|
|
f2f352d06c | ||
|
|
034707a9d7 | ||
|
|
eb8d3a2fef | ||
|
|
fb22c1547c | ||
|
|
7b26949c76 | ||
|
|
37f1d3f0fd | ||
|
|
a4becb5d1f | ||
|
|
a19b3c8d62 | ||
|
|
80dea409c6 | ||
|
|
7c762d8a98 | ||
|
|
15dff4a9e1 | ||
|
|
c6a32a2f5a | ||
|
|
f633d82f51 | ||
|
|
f22abc9861 | ||
|
|
c5897bc50a | ||
|
|
cb80ea98ba | ||
|
|
b2fb63988f | ||
|
|
70b443a716 | ||
|
|
88d715938f | ||
|
|
7620386215 | ||
|
|
b6b1a47a7b | ||
|
|
b92112da54 | ||
|
|
03e6f119e5 | ||
|
|
b9ddfc1596 | ||
|
|
b46ed37141 | ||
|
|
d1a54134bb | ||
|
|
99378dc02d | ||
|
|
6f35348c7c |
No files matched your search
@@ -0,0 +1,91 @@
|
||||
# Agent skills
|
||||
|
||||
This folder is the single home for the skills our coding agents use.
|
||||
Each skill is a folder with a `SKILL.md` inside — a short instruction
|
||||
manual that an agent loads only when it needs it.
|
||||
|
||||
One copy serves every tool:
|
||||
|
||||
- **opencode** reads this folder directly.
|
||||
- **Claude Code** reads it through the `.claude/skills` symlink.
|
||||
- **Codex** reads it directly.
|
||||
|
||||
To change how the agents behave, edit the `SKILL.md` here. There is no
|
||||
second copy to keep in sync.
|
||||
|
||||
## How the skills are organized
|
||||
|
||||
**Flows** are the six skills you invoke by name. Each one covers one step
|
||||
in the life of a change: plan it, review the plan, implement it, review
|
||||
the code, open the pull request.
|
||||
|
||||
**References** hold the quality standards. A flow's reviewer loads them;
|
||||
you rarely touch them directly.
|
||||
|
||||
**Procedures** define how one concrete step is done — a plan document, an
|
||||
issue, a commit. Flows call them, but they also work on their own.
|
||||
|
||||
**Utilities** are small helpers for everyday work: search, file lookup,
|
||||
JSON, REPL access, and so on.
|
||||
|
||||
## Flows
|
||||
|
||||
| Skill | What it does | When you would say |
|
||||
|---|---|---|
|
||||
| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
|
||||
| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
|
||||
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. On request ("direct"): no issue and no branch, commits on the current branch. | "implement the plan" · "step by step, one commit per task" · "direct, no branch" |
|
||||
| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
|
||||
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
|
||||
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
|
||||
|
||||
## References
|
||||
|
||||
| Skill | What it holds |
|
||||
|---|---|
|
||||
| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. |
|
||||
| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. |
|
||||
|
||||
## Procedures
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`planner`](skills/planner/SKILL.md) | The spec of a good plan: context, architecture decisions, tasks with acceptance criteria, checkpoints. Used by `make-a-plan`. |
|
||||
| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. |
|
||||
| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`bat-cat`](skills/bat-cat/SKILL.md) | Read files in the terminal with syntax highlighting and line numbers. |
|
||||
| [`fd-find`](skills/fd-find/SKILL.md) | Find files by name or pattern, respecting `.gitignore`. |
|
||||
| [`ripgrep`](skills/ripgrep/SKILL.md) | Fast content search with regular expressions. |
|
||||
| [`jq-json-processor`](skills/jq-json-processor/SKILL.md) | Slice, filter, and reshape JSON output. |
|
||||
| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
|
||||
| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
|
||||
| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
|
||||
| [`local-ci`](skills/local-ci/SKILL.md) | Run CI-style lint, test, and format checks for the modules you touched with `scripts/ci`, and read the logs when they fail. |
|
||||
| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
|
||||
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
|
||||
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
|
||||
| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
|
||||
|
||||
## A typical round
|
||||
|
||||
1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`.
|
||||
2. `/review-plan` — a second opinion; approve or request changes.
|
||||
3. `/implement-plan` — the code gets written and committed. Starting from a base branch, it also opens the GitHub issue and the `issue-NNNN` branch; the plans that follow continue on that same branch.
|
||||
4. `/review-code` — a reviewer checks the commit.
|
||||
5. `/create-pr` — the branch goes up as a pull request.
|
||||
|
||||
Every step also works on its own, and you can always say what you want
|
||||
in plain words — the agents pick the right skill from what you say.
|
||||
|
||||
## Adding or changing a skill
|
||||
|
||||
Create a folder here with a `SKILL.md` inside. The file needs `name` and
|
||||
`description` in its frontmatter, and a clear "When to use" section so
|
||||
agents know when to reach for it. Keep one job per skill, and keep the
|
||||
two families apart: flows are named with a verb first; reference skills
|
||||
end in `-criteria`.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i
|
||||
|
||||
`cat` with syntax highlighting, line numbers, and Git integration.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reading or displaying a file in the terminal — prefer it over plain
|
||||
`cat`: syntax highlighting, line numbers, git-side indicators.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic usage
|
||||
+7
-8
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||
name: code-review-criteria
|
||||
description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow.
|
||||
---
|
||||
|
||||
# Code Review and Quality
|
||||
# Code Review Criteria and Quality
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,11 +13,10 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before merging any PR or change
|
||||
- After completing a feature implementation
|
||||
- When another agent or model produced code you need to evaluate
|
||||
- When refactoring existing code
|
||||
- After any bug fix (review both the fix and the regression test)
|
||||
- The reviewer subagent of the `review-code` flow loads this skill to perform
|
||||
the review of a code change.
|
||||
- To review code, always go through the `review-code` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
|
||||
## Core Principles
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Create PR
|
||||
|
||||
Two modes. **Open mode** takes the current task branch to a new, validated
|
||||
PR. **Update mode** rewrites an existing PR's title or description. Gather
|
||||
information, validate, and act in one pass. If validation fails, STOP with a
|
||||
single coherent message that lists every problem and states exactly what
|
||||
information is missing — never fix or work around problems silently.
|
||||
|
||||
Both modes require an authenticated `gh` CLI (`gh auth status`) and never
|
||||
push — the user pushes from their own shell.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to open or create a NEW PR for the current task branch, in
|
||||
any phrasing ("open a PR", "create the pull request", "put this up for
|
||||
review") — or runs `/create-pr`. → **Open mode**.
|
||||
- The user asks to fix or update an EXISTING PR's title or description to
|
||||
match conventions. → **Update mode**.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent.
|
||||
|
||||
## Open mode
|
||||
|
||||
### 1. Gather context (read-only)
|
||||
|
||||
- Current branch: `git rev-parse --abbrev-ref HEAD`.
|
||||
- Target base branch: run `./scripts/detect-target-branch` from the repo root.
|
||||
It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1).
|
||||
- Commits: `git log --oneline <base>..HEAD`.
|
||||
- Push state (local): `git rev-parse --verify origin/<branch>` and compare
|
||||
with HEAD. It reads the local remote-tracking ref — no network, no SSH. It
|
||||
reflects the last push or fetch this clone knows about.
|
||||
- Issue: from the session context, or from the branch name — `issue-NNNN`
|
||||
maps to issue NNNN; recover its title and body with `gh issue view NNNN`.
|
||||
|
||||
### 2. Validate — stop with one message if anything fails
|
||||
|
||||
Run all checks before reporting, then report every failure together:
|
||||
|
||||
1. **Base branch not usable.** If the script fails (exit 1), or its output —
|
||||
after stripping an optional `remotes/origin/` prefix — is not one of the
|
||||
canonical branches (`develop`, `staging`, `main`), stop and ask the user
|
||||
to re-run with more context — for example, passing the base branch
|
||||
explicitly in their invocation. An explicit base given by the user
|
||||
overrides the script's output.
|
||||
2. **On a base branch.** There is no task branch to merge — say so and stop.
|
||||
3. **No commits.** The branch has no commits ahead of the base — say so and
|
||||
stop.
|
||||
4. **No clear issue.** There is no issue in the session context, and the
|
||||
branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing)
|
||||
— say so and stop. Exception: the user's invocation says `no issue` /
|
||||
`without issue` — then continue without an issue reference.
|
||||
5. **Branch not pushed.** The remote-tracking ref `origin/<branch>` is
|
||||
missing, or `git rev-parse origin/<branch>` differs from HEAD — the
|
||||
branch was never pushed, or has commits the remote does not have. Never
|
||||
push yourself; ask the user to push and to run `/create-pr` again
|
||||
afterwards, then stop.
|
||||
|
||||
### 3. Already-open PR
|
||||
|
||||
Check whether a PR already exists for this branch (`gh pr list --head
|
||||
<branch>`). If one exists, report its URL and stop — do not create a second
|
||||
one. Title or description fixes belong to Update mode.
|
||||
|
||||
### 4. Write and create the PR
|
||||
|
||||
Write the title and body following `mem:workflow/creating-prs` (title format,
|
||||
description structure, writing principles) and `mem:workflow/creating-commits`
|
||||
(commit type emojis). Derive the title and body from the commits and, when
|
||||
there is one, from the issue body. Reference the issue with `Closes #NNNN`.
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
Report the PR URL and stop.
|
||||
|
||||
## Update mode
|
||||
|
||||
1. Identify the PR: the number given by the user, or `gh pr list --head
|
||||
<branch>`.
|
||||
2. Write the new title and/or body following `mem:workflow/creating-prs`.
|
||||
3. Apply and verify:
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
|
||||
4. Report and stop.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: overrides such as `no issue` /
|
||||
`without issue`, an explicit base branch (`from origin/staging`), a PR number
|
||||
for Update mode, and so on.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id
|
||||
|
||||
User-friendly alternative to `find` with smart defaults.
|
||||
|
||||
## When to use
|
||||
|
||||
- Locating files or directories by name or pattern — prefer it over
|
||||
plain `find`: simpler syntax, smart defaults, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: implement-plan
|
||||
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each; on request ("direct"), no issue and no branch — the commit lands on the current branch. Use it when the user asks to implement or execute a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Implement Plan
|
||||
|
||||
This flow is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. It never pushes —
|
||||
the user pushes.
|
||||
|
||||
By default it ends with exactly one commit. When the user asks for it
|
||||
("step by step"), it commits once per task instead and waits for the
|
||||
user's confirmation after each one (see *Execution modes*).
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to implement or execute a plan, in any phrasing:
|
||||
"implement the plan", "execute it", "go build it" — or runs
|
||||
`/implement-plan`.
|
||||
- A ready, reviewed plan is in the session context or a plan file path
|
||||
was given (typically after `/make-a-plan` or `/review-plan`).
|
||||
|
||||
Do not use it to produce plans — that is the `make-a-plan` flow.
|
||||
|
||||
## 1. Read the plan first
|
||||
|
||||
Identify the plan to execute — from the file path the user gave, the
|
||||
arguments, or the session context. Read it completely. Read the required
|
||||
memories before writing any code: `mem:critical-info` and the core memory
|
||||
of every module the plan touches, plus the deeper memories they reference
|
||||
(AGENTS.md governs this).
|
||||
|
||||
## 2. Detect the flow (no questions)
|
||||
|
||||
Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the
|
||||
mode, and announce it in one line before presenting anything. Detection is
|
||||
read-only: nothing is created until the user confirms (step 3).
|
||||
|
||||
- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**:
|
||||
a new GitHub issue and a branch `issue-NNNN` will be created after the
|
||||
user's confirmation.
|
||||
- **On any other branch** (a feature branch, typically `issue-NNNN`) →
|
||||
**continue mode**: the implementation continues on the current branch.
|
||||
No issue or branch is created. The branch name provides the issue
|
||||
reference when it follows the `issue-NNNN` pattern.
|
||||
|
||||
Arguments override detection: `standalone`, `continue`, `direct`
|
||||
(`no branch` / `direct commit`), `no issue` / `without issue`, or an
|
||||
explicit base such as `from origin/develop`.
|
||||
|
||||
**Direct mode** (`direct`, `no branch`, `direct commit`): no issue and
|
||||
no branch — the implementation and the commit land on the current branch
|
||||
as it is, even when it is a base branch. Best for small or tooling-only
|
||||
changes the user wants committed in place.
|
||||
|
||||
**Standalone while already on a feature branch:** stop and explain that this
|
||||
would stack branches. Ask the user to re-run with an explicit base, for
|
||||
example `from origin/develop` — then branch from that base instead of HEAD.
|
||||
|
||||
## 3. Present the checklist and wait
|
||||
|
||||
Before touching the repository, show the user the full picture:
|
||||
|
||||
- **The flow**: whether the GitHub issue and the branch will be created
|
||||
(standalone mode — give the planned branch name, `issue-NNNN` or
|
||||
`plan-<slug>`), whether you continue on the current branch
|
||||
(continue mode — name it), or whether everything lands on the current
|
||||
branch as it is (direct mode — name it, and say so when it is a base
|
||||
branch).
|
||||
- **The execution style**: batch or step-by-step (see *Execution modes*).
|
||||
- A checklist (todolist) of the plan's tasks, in order.
|
||||
|
||||
Then WAIT for the user's explicit confirmation. Do not start until you
|
||||
have it. If the plan has no discrete tasks, ask the user how to split
|
||||
it, or propose running it as a single change.
|
||||
|
||||
## 4. Execute the plan
|
||||
|
||||
**Standalone setup, after the confirmation:** create the issue with the
|
||||
**`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body
|
||||
from the plan, capture the new issue's number — call it **NNNN** — and
|
||||
create the branch from the current HEAD:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
If the arguments say `no issue` / `without issue`, skip the issue and
|
||||
create a branch named `plan-<slug>` instead, where `<slug>` is the plan
|
||||
title, lowercase and hyphen-separated.
|
||||
|
||||
If the arguments say `direct` / `no branch` / `direct commit`, skip the
|
||||
issue and the branch: implement and commit on the current branch as it
|
||||
is. If it is a base branch, the checklist presentation already said so —
|
||||
no further confirmation is needed.
|
||||
|
||||
### Batch mode (default)
|
||||
|
||||
Implement every task in one go. Work methodically, keeping changes
|
||||
focused on what the issue requires. Respect the plan's proposed
|
||||
parallelization when it applies.
|
||||
|
||||
When the implementation is complete, load the **`create-commit`** skill
|
||||
and follow its workflow to commit the changes. Provide a brief summary
|
||||
of what was implemented and why, the issue reference (`issue-NNNN`) when
|
||||
there is one, and the model name you are running as so the
|
||||
`AI-assisted-by` trailer is set correctly.
|
||||
|
||||
### Step-by-step mode (on request)
|
||||
|
||||
When the user asks for it — "step by step", "task by task", "one commit
|
||||
per task" — loop one task at a time:
|
||||
|
||||
- Execute exactly ONE task.
|
||||
- Commit it now: load the **`create-commit`** skill and follow it —
|
||||
one commit per task, never two tasks in one commit. Same inputs as
|
||||
always: what and why, the issue reference, your model name.
|
||||
- Show the user the result (what changed, files touched, how it was
|
||||
verified).
|
||||
- WAIT for the user's confirmation before starting the next task.
|
||||
|
||||
Never batch in this mode: no two tasks in one commit, and no new task
|
||||
before the user confirms. If a task turns out much bigger than planned,
|
||||
stop and ask the user before splitting it.
|
||||
|
||||
## When you are done
|
||||
|
||||
End by suggesting the next steps (suggestions, not a required pipeline — any
|
||||
instruction from me overrides them):
|
||||
|
||||
- `/review-code` — to review the changes just committed; it routes to
|
||||
`/make-a-plan` by itself if the findings need one.
|
||||
- `/create-pr` — when the task is done and the branch is ready to merge.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this
|
||||
skill) plays the role command arguments play elsewhere: `standalone`,
|
||||
`continue`, `direct` (`no branch` / `direct commit`), `no issue` /
|
||||
`without issue`, an explicit base such as `from origin/develop`, or
|
||||
`step by step` / `one commit per task` for the step-by-step execution
|
||||
mode. Modes combine freely, for example "standalone step by step".
|
||||
+5
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id
|
||||
|
||||
Process, filter, and transform JSON data with jq.
|
||||
|
||||
## When to use
|
||||
|
||||
- Parsing, filtering, or transforming JSON from commands, files, or API
|
||||
responses — slicing, reshaping, or validating JSON output.
|
||||
|
||||
## Quick Examples
|
||||
|
||||
### Basic filtering
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: local-ci
|
||||
description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
|
||||
---
|
||||
|
||||
# Local CI
|
||||
|
||||
Run the same checks CI runs, locally, for the modules you touched, with
|
||||
`scripts/ci`. Each task writes a log file; the final summary says what
|
||||
passed and what failed.
|
||||
|
||||
Full details: `mem:scripts/ci` (file: `.serena/memories/scripts/ci.md`)
|
||||
|
||||
## When to use
|
||||
|
||||
- After implementing or fixing code — verify every module you touched
|
||||
before declaring the work done.
|
||||
- When the user asks to run CI, lint, tests, or format checks locally.
|
||||
- When you changed `common/` — validate its consumers too.
|
||||
|
||||
**Skip:** while exploring, planning, or reading code.
|
||||
|
||||
## Command reference
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/ci [OPTIONS] [MODULES...]
|
||||
```
|
||||
|
||||
Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
|
||||
`plugins` `library`, or `--all` for every module.
|
||||
|
||||
With no task flags it runs three tasks per module, in order: **lint**,
|
||||
**test**, **fmt** (format check; `--fix` formats files instead).
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--all` | Run every module |
|
||||
| `--exclude MOD` | Skip one module (repeatable) |
|
||||
| `--lint` / `--no-lint` | Run only lint / drop lint |
|
||||
| `--test` / `--no-test` | Run only tests / drop tests |
|
||||
| `--fmt` / `--no-fmt` | Run only format check / drop it |
|
||||
| `--fix` | Format files instead of checking (other tasks unaffected) |
|
||||
| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
|
||||
| `--fail-fast` | Stop at the first failure |
|
||||
| `--quiet` | Suppress failure output |
|
||||
| `--dry-run` | Show what would run, execute nothing |
|
||||
| `--clean` | Delete the `.ci-logs/` directory |
|
||||
|
||||
## Reading failures
|
||||
|
||||
Every task writes its full output to `.ci-logs/<module>-<task>.log`. On
|
||||
failure the script prints only the last 30 lines. To diagnose a failure,
|
||||
**read the log file** — never re-run the command piped through filters
|
||||
(repo rule: redirect to a file first, then read it). The exit code is 1
|
||||
when any task failed; the summary lists each failed `module:task` and its
|
||||
log path.
|
||||
|
||||
## Typical workflows
|
||||
|
||||
```bash
|
||||
# Verify a module you changed: lint + tests + format check
|
||||
./scripts/ci frontend
|
||||
|
||||
# Fast pass while iterating: lint only
|
||||
./scripts/ci --lint frontend
|
||||
|
||||
# Lint + format check, skip the long test suite
|
||||
./scripts/ci --no-test frontend
|
||||
|
||||
# Format the module without running the test suite
|
||||
./scripts/ci --fix --no-test frontend
|
||||
|
||||
# Broke delimiters in Clojure/CLJS files: repair first, then lint
|
||||
./scripts/ci --paren-repair frontend
|
||||
./scripts/ci --lint frontend
|
||||
|
||||
# Changed common/ — validate its consumers too
|
||||
./scripts/ci frontend backend exporter
|
||||
|
||||
# Preview what would run, without running it
|
||||
./scripts/ci --dry-run --all
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Run from the repo root.
|
||||
- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
|
||||
the bash call a generous timeout (10–20 minutes) instead of letting it
|
||||
time out mid-run.
|
||||
- `mcp` has no lint task — it shows as skipped, not failed.
|
||||
- `--paren-repair` only fixes delimiters; run lint afterwards to catch
|
||||
what remains. See `mem:scripts/paren-repair`.
|
||||
- What to run and how to read test results: `mem:testing`.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: make-a-plan
|
||||
description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .agents/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing.
|
||||
---
|
||||
|
||||
# Make a Plan
|
||||
|
||||
Act as a senior software engineer: research the subject of this session in depth and
|
||||
produce a well-grounded, actionable implementation plan.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent to save the plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to plan, design, or break down a task, in any phrasing:
|
||||
"make a plan", "how would we build X", "design an approach for Y" —
|
||||
or runs `/make-a-plan`.
|
||||
- The user asks to rework or extend an existing plan (for example, after
|
||||
review findings) — revise the saved plan file in place.
|
||||
|
||||
Do not use it to execute a plan — that is the `implement-plan` flow.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Produce the plan** with the `planner` skill. By default, research the
|
||||
subject of this session and draft the plan yourself. If I ask for it (for
|
||||
example, `delegated` in the user context), delegate to the `general` subagent
|
||||
instead — the delegate must also follow the `planner` skill and receive all
|
||||
the relevant session context (a review, user feedback, and so on).
|
||||
2. Before asking me to decide anything, explain the plan and every open question in
|
||||
plain language. Assume I know only the high-level project goal, not the codebase,
|
||||
architecture, implementation terms, or the problem this task solves.
|
||||
3. Once all decisions are answered and the plan is final, save it verbatim to the
|
||||
announced path under `.agents/plans/` (create the directory if it does not
|
||||
exist). This step is the flow's explicit authorization to write the plan
|
||||
file — the only write allowed here. If I later ask for changes, update the
|
||||
saved file directly.
|
||||
4. Present me with a clear, self-contained summary of the plan's most relevant points
|
||||
only after all required decisions have been answered. Write it for someone who knows
|
||||
only the project's high-level goal and may not know the plan's low-level context.
|
||||
Explain necessary technical language in plain terms, include the problem being
|
||||
solved and the proposed outcome, and do not assume that listing technical task names
|
||||
is enough.
|
||||
|
||||
### Hard rule — read-only while planning
|
||||
|
||||
While this flow runs, act read-only: research with read-only tools only.
|
||||
Never edit source files, never run builds, tests, linters, or any command that
|
||||
modifies state, and never commit. The single allowed write is the plan file in
|
||||
step 3. This rule expires when I approve the plan or move on to another task;
|
||||
then you act as a normal build agent again.
|
||||
|
||||
When the plan contains open questions, do not show them as bare technical questions or
|
||||
assume that I understand the technical language or technical words used in the plan.
|
||||
For each question, first explain:
|
||||
|
||||
- What part of the user problem the decision affects.
|
||||
- The relevant concept from the beginning, with a small concrete example.
|
||||
- What each available option would make the system do.
|
||||
- The practical benefits, costs, risks, and user-visible consequences of each option.
|
||||
- Which option the planner recommends and why.
|
||||
|
||||
Only after that explanation, use the `question` tool to ask the decision with clear,
|
||||
non-technical option labels. Put the recommended option first and mark it as
|
||||
`(Recommended)`. Group related questions when their context is shared, but do not ask a
|
||||
question whose meaning has not already been explained.
|
||||
|
||||
If I say that I do not understand a question or its choices, do not treat my previous
|
||||
answer as valid. Explain the concepts again from the high-level project goal, use a more
|
||||
concrete example, explain the implications, and ask the question again with the
|
||||
`question` tool. Repeat this until I can make an informed choice. If one answer creates
|
||||
new design consequences or additional decisions, explain those consequences before
|
||||
asking any new question.
|
||||
|
||||
Distinguish clearly between requirements already fixed by the roadmap or existing
|
||||
architecture and choices that actually require my input. Do not ask me to choose an
|
||||
implementation detail when the plan can resolve it safely without changing the public
|
||||
behavior. If there are no decisions that require my input, say so and present the
|
||||
summary.
|
||||
|
||||
IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it
|
||||
after all possible questions have been answered.** The final summary must explain the
|
||||
problem being solved, the proposed behavior, the main user-visible workflow, important
|
||||
constraints and risks, what is deliberately out of scope, and the path where the plan
|
||||
is saved. Never assume that a short list of task names is enough context. End
|
||||
the final response by suggesting the next steps, in this order:
|
||||
|
||||
1. `/review-plan` — to get a second opinion on the plan before executing it.
|
||||
2. `/implement-plan` — to execute the plan from the current session context.
|
||||
|
||||
These are suggestions, not a required pipeline — any instruction from me
|
||||
overrides them (for example, asking you to implement the plan directly).
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, `delegated` to
|
||||
hand the research and drafting to the `general` subagent, or corrections and
|
||||
feedback about a previous plan.
|
||||
@@ -10,6 +10,12 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
|
||||
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
|
||||
|
||||
## When to use
|
||||
|
||||
- Evaluating Clojure or ClojureScript code against the running nREPL
|
||||
sessions (backend 6064, frontend 3447) — live inspection, patching, or
|
||||
debugging.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-review
|
||||
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
|
||||
name: plan-review-criteria
|
||||
description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow.
|
||||
---
|
||||
|
||||
# Plan Review
|
||||
# Plan Review Criteria
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor
|
||||
|
||||
## When to Use
|
||||
|
||||
- After the planner skill produces a plan
|
||||
- Before starting implementation on any non-trivial task
|
||||
- When reviewing a plan written by another agent or a human
|
||||
- When a plan feels too large, vague, or risky to start
|
||||
- The reviewer subagent of the `review-plan` flow loads this skill to perform
|
||||
the review of a plan.
|
||||
- To review a plan, always go through the `review-plan` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
|
||||
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
|
||||
|
||||
@@ -87,7 +87,7 @@ Can an implementer actually execute this?
|
||||
|
||||
### 6. Proposed Code Quality *(when the plan includes implementation details)*
|
||||
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`:
|
||||
|
||||
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
|
||||
- **Readability:** Are proposed names descriptive and consistent with project conventions?
|
||||
@@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked:
|
||||
If the plan includes code snippets, types, or API designs:
|
||||
|
||||
```
|
||||
- Load code-review skill for criteria
|
||||
- Load code-review-criteria skill for criteria
|
||||
- Check proposed signatures for edge cases
|
||||
- Verify naming follows project conventions
|
||||
- Confirm abstractions follow existing patterns
|
||||
@@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs:
|
||||
## See Also
|
||||
|
||||
- For producing plans, use the `planner` skill
|
||||
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
|
||||
- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6
|
||||
- For security-specific concerns, see `security-and-hardening`
|
||||
- For testing strategy guidance, see `testing`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
|
||||
---
|
||||
|
||||
# Planner
|
||||
@@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands:
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code.
|
||||
- The only file write you may attempt is the plan itself, saved to
|
||||
`.opencode/plans/`.
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
@@ -228,22 +228,23 @@ Add explicit checkpoints with the relevant module commands:
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill.
|
||||
of which agent is running the skill. By default you never write the plan file;
|
||||
announce the path instead. Write the file only when the command or user
|
||||
explicitly instructs you to save it — and then only that file.
|
||||
|
||||
Additionally, save the plan to:
|
||||
Announce the suggested save path:
|
||||
|
||||
```
|
||||
.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
|
||||
`.opencode/plans/` directory if it does not exist.
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
|
||||
provides a target file path, announce that path instead of the default.
|
||||
|
||||
IMPORTANT: The plan agent has write permission specifically for
|
||||
`.opencode/plans/` — always attempt the write. If the user explicitly provides
|
||||
a target file path, use that path instead of the default.
|
||||
End the response by suggesting the next steps: `/review-plan` to get a second
|
||||
opinion on the plan and `/implement-plan` to execute it.
|
||||
|
||||
### Plan Document Template
|
||||
|
||||
@@ -374,4 +375,6 @@ Before delivering the plan, confirm:
|
||||
- [ ] Task dependencies are identified and ordered correctly
|
||||
- [ ] No task is XL or larger — break it down instead
|
||||
- [ ] Checkpoints exist after every 2-3 tasks
|
||||
- [ ] The response states the plan's path (saved or suggested) and suggests
|
||||
`/review-plan` and `/implement-plan`
|
||||
- [ ] The plan is ready for human review
|
||||
File renamed without changes.
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: resolve-git-conflicts
|
||||
description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them.
|
||||
---
|
||||
|
||||
# Resolve Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## When to use
|
||||
|
||||
- The repository has unresolved conflicts — during a rebase, merge, or
|
||||
cherry-pick — whether the user asks about them or not.
|
||||
- The user asks to resolve conflicts, in any phrasing: "fix the merge
|
||||
conflicts", "resolve these", "what's conflicting here?".
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: review-code
|
||||
description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Code
|
||||
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review code, in any phrasing: "review this diff",
|
||||
"review the PR", "check my changes", "code review" — or runs
|
||||
`/review-code`.
|
||||
- A commit, branch, PR, or diff is ready and the user wants it assessed
|
||||
before merge.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine what is being reviewed** from the user context: a working-tree
|
||||
diff, a commit range, a branch, a PR (number or URL), or specific files. If
|
||||
the target is ambiguous, ask before reviewing.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`code-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest how to proceed based on the findings. These
|
||||
are suggestions — the user decides:
|
||||
- **Approve (no required changes):** say so — there is nothing to address.
|
||||
- **Minor findings (nits):** applying them directly as-is is fine once the
|
||||
review is done — no plan needed.
|
||||
- **Substantive findings:** suggest `/make-a-plan` to make a plan to address
|
||||
them.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`code-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related testing documentation from memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged
|
||||
finding — never as a recommendation.
|
||||
6. Skip generated files, lockfile-only changes, and unrelated modifications
|
||||
unless they introduce security risks.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a PR number or
|
||||
URL, a commit range, specific files, or a different agent to run the review.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: review-plan
|
||||
description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Plan
|
||||
|
||||
Act as a senior software engineer and perform a thorough review of an
|
||||
implementation plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review a plan, in any phrasing: "review this plan",
|
||||
"does this plan look right?", "second opinion on the plan" — or runs
|
||||
`/review-plan`.
|
||||
- A plan was just produced (typically by `/make-a-plan`) and the user
|
||||
wants it evaluated before executing it.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine the plan under review** from the session context (for example, a
|
||||
plan just produced by `/make-a-plan`) or from a plan file path given by the
|
||||
user (typically under `.agents/plans/`). If a file path is given, read the
|
||||
file first so the complete plan is in context.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`plan-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest the next step based on the verdict. These
|
||||
are suggestions — the user decides, and any instruction overrides them:
|
||||
- **Approve** → suggest `/implement-plan` to execute it.
|
||||
- **Request changes** → suggest `/make-a-plan` to make a plan to address the
|
||||
findings.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`plan-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related documentation and testing memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Judge the plan as the implementer would: every task executable without
|
||||
guessing, ordering follows the dependency graph, risks named.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a plan file path
|
||||
to review, or a different agent to run the review.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id
|
||||
|
||||
Fast, smart recursive search. Respects `.gitignore` by default.
|
||||
|
||||
## When to use
|
||||
|
||||
- Searching file contents across the repo for regex patterns — the
|
||||
default code search, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
File renamed without changes.
@@ -9,6 +9,13 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno
|
||||
|
||||
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
|
||||
|
||||
## When to use
|
||||
|
||||
Only when the user explicitly invokes it: they type `/ste`, or say "use
|
||||
the ste skill" / "apply ASD-STE100". Requests like "simplify this",
|
||||
"make it clearer", or "shorter sentences" do NOT invoke it — respond
|
||||
normally unless it is named.
|
||||
|
||||
## Step 0 — Classify the text
|
||||
|
||||
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
|
||||
File renamed without changes.
File renamed without changes.
@@ -11,6 +11,12 @@ Fetch information from Taiga public API for the **Penpot** project
|
||||
|
||||
**No authentication required** — only public project data is accessed.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks about Penpot issues, user stories, or tasks tracked in
|
||||
Taiga — fetch them via the public API (project id 345963), no
|
||||
authentication needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
|
||||
File renamed without changes.
File renamed without changes.
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -0,0 +1,44 @@
|
||||
name: _ADHOC
|
||||
|
||||
run-name: >-
|
||||
_ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Branch/ref to build in penpot/penpot'
|
||||
type: string
|
||||
required: true
|
||||
nitrate_ref:
|
||||
description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
|
||||
type: string
|
||||
required: false
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
@@ -9,6 +9,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -16,6 +21,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
@@ -34,6 +44,8 @@ jobs:
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -48,10 +60,12 @@ jobs:
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The uploaded zip carries its version as S3 metadata. If the
|
||||
# existing object was already built from this same commit, the
|
||||
# whole build job is skipped.
|
||||
# whole build job is skipped. `force` bypasses this check entirely.
|
||||
- name: Check if this bundle is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -59,6 +73,16 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### 🔁 Bundle build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 version check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXISTING_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
|
||||
@@ -117,6 +141,16 @@ jobs:
|
||||
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Bundle built"
|
||||
echo ""
|
||||
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
name: _DEVELOP
|
||||
|
||||
run-name: >-
|
||||
_DEVELOP (develop @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
@@ -15,6 +24,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -22,9 +32,11 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -24,6 +29,11 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ORG_WORKFLOW_TOKEN:
|
||||
description: 'Token with Actions write access on penpot-nitrate'
|
||||
@@ -47,6 +57,7 @@ jobs:
|
||||
|
||||
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
|
||||
-f gh_ref="$GH_REF" \
|
||||
-f force="${{ inputs.force }}" \
|
||||
-f caller_run_id="$DISTINCT_ID" \
|
||||
-f caller_run_url="$CALLER_URL"
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -15,6 +20,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
@@ -41,7 +51,8 @@ jobs:
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
build_key: ${{ steps.vars.outputs.build_key }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -59,6 +70,8 @@ jobs:
|
||||
run: |
|
||||
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
BUNDLE_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
@@ -67,15 +80,11 @@ jobs:
|
||||
--output text)
|
||||
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Image content = bundle + docker build context, so the build key
|
||||
# combines both.
|
||||
CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
|
||||
echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
# The image set is a single block, so a single set-level check is
|
||||
# enough: `promote` drops a marker object in S3 only after every
|
||||
# image was built AND every branch tag was moved. Marker present
|
||||
# means there is nothing at all to do for this build key.
|
||||
# means there is nothing at all to do for this commit. `force`
|
||||
# bypasses this check entirely.
|
||||
- name: Check if this image set is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -83,15 +92,30 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
{
|
||||
echo "### 🔁 Image set build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 marker check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "markers/images-${{ steps.vars.outputs.build_key }}" \
|
||||
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
|
||||
> /dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### ⏭️ Image set build skipped"
|
||||
echo ""
|
||||
echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
|
||||
echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
@@ -101,7 +125,7 @@ jobs:
|
||||
# prune stale bundles while at it.
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
@@ -142,7 +166,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# To avoid the "429 Too Many Requests" error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
@@ -173,7 +197,7 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
echo "Bundle not found in host cache; falling back to S3."
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
@@ -213,7 +237,7 @@ jobs:
|
||||
sbom: true
|
||||
# Immutable tag only; branch tags are moved atomically for the
|
||||
# whole image set by the `promote` job.
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
|
||||
@@ -249,7 +273,7 @@ jobs:
|
||||
for image in $ALL_IMAGES; do
|
||||
docker buildx imagetools create \
|
||||
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
|
||||
"${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
|
||||
"${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}"
|
||||
done
|
||||
|
||||
# The marker is written LAST: its presence certifies that all five
|
||||
@@ -261,11 +285,18 @@ jobs:
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Image set promoted"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
|
||||
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
name: _STAGING
|
||||
|
||||
run-name: >-
|
||||
_STAGING (staging)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
@@ -15,6 +24,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -22,9 +32,11 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -1,7 +1,16 @@
|
||||
name: _TAG
|
||||
|
||||
run-name: >-
|
||||
_TAG (${{ github.ref_name }} @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -18,6 +27,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -25,12 +35,14 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
|
||||
@@ -102,17 +102,14 @@ jobs:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests (${{ matrix.shard }})"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }}
|
||||
timeout-minutes: 40
|
||||
|
||||
needs: build-integration
|
||||
|
||||
# TEMPORARY (release stabilization): PRs targeting `staging` run on a
|
||||
# single serial shard, so new flakes cannot block the release work.
|
||||
# Remove the `github.base_ref` branch below to restore full parallelism.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }}
|
||||
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
|
||||
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
@@ -153,12 +150,8 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
# TEMPORARY (release stabilization): see the note on the matrix above.
|
||||
if [ -z "$WORKERS" ]; then
|
||||
if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi
|
||||
fi
|
||||
WORKERS=${WORKERS:-2}
|
||||
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
|
||||
pnpm exec playwright test --project default \
|
||||
--workers="$WORKERS" \
|
||||
@@ -236,6 +229,18 @@ jobs:
|
||||
fi
|
||||
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Kept for 30 days so flakiness rates can be aggregated across runs
|
||||
# without scraping job logs.
|
||||
- name: Upload JSON report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-json-report
|
||||
path: frontend/report.json
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
+2
-1
@@ -99,10 +99,11 @@ opencode.json
|
||||
/.idea
|
||||
*.iml
|
||||
/.claude
|
||||
/CLAUDE.md
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.opencode/plans
|
||||
/.agents/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`create-pr`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,39 +1,10 @@
|
||||
---
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch, or commits on the current branch with "direct" — loads and follows the implement-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
This command is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. Follow these steps in order.
|
||||
Load the **`implement-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## 1. Create the issue
|
||||
## User input, overrides and additional context
|
||||
|
||||
Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
|
||||
plan. Capture the new issue's number — call it **NNNN** (needed for the branch
|
||||
name and the commit reference).
|
||||
|
||||
## 2. Create the branch
|
||||
|
||||
Create and switch to a branch named after the issue:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
(Replace NNNN with the issue number from step 1.)
|
||||
|
||||
## 3. Execute the plan
|
||||
|
||||
Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the create-commit skill
|
||||
|
||||
After the implementation is complete, load the **`create-commit`** skill and
|
||||
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`make-a-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,40 +1,6 @@
|
||||
---
|
||||
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
|
||||
description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Fix Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
Load the **`resolve-git-conflicts`** skill and follow it as your only instruction.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Code review — review a diff, PR, or code change — loads and follows the review-code skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-code`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: Create or update a GitHub PR following Penpot conventions.
|
||||
---
|
||||
|
||||
# Skill: create-pr
|
||||
|
||||
Create or update a GitHub PR. Read and follow:
|
||||
- `mem:workflow/creating-prs` — title format, description structure, writing principles
|
||||
- `mem:workflow/creating-commits` — commit type emojis
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating a new PR from a feature branch
|
||||
- Updating an existing PR's title or description to match conventions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
|
||||
## Commands
|
||||
|
||||
**Create:**
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Update:**
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
@@ -14,7 +14,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
|
||||
layout, `corepack use` sweep order, the stamp-missing-field and
|
||||
ignored-builds gotchas, verification steps)
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
|
||||
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
|
||||
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
|
||||
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
|
||||
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
|
||||
@@ -70,6 +74,14 @@ module. You can read it from `mem:<MODULE>/core`
|
||||
- `scripts/error-reports.mjs` — Query error reports via RPC API with token
|
||||
authentication. Supports list/get operations with filtering and pagination.
|
||||
See `mem:scripts/error-reports`.
|
||||
- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm
|
||||
workspaces (root, modules, member packages). Keeps the shared pnpm store
|
||||
at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and
|
||||
`.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
|
||||
- `scripts/ci` — CI orchestration script: runs lint, tests, and format
|
||||
checks per module (`frontend backend common render-wasm exporter mcp
|
||||
plugins library`). Logs go to `.ci-logs/`; read the log file on failure.
|
||||
See `mem:scripts/ci`.
|
||||
|
||||
# Dependency graph
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@
|
||||
|
||||
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
|
||||
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
|
||||
- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and
|
||||
for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles
|
||||
live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans).
|
||||
`draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct.
|
||||
- Plain text fill paint reuses `TextContent.layout` paragraphs when
|
||||
`has_usable_paint_layout` (paragraphs present + version match; during
|
||||
interactive transforms rotation/move skips width check via
|
||||
`modifier_changes_text_layout`, resize falls back to `layout_width` vs
|
||||
`get_width(selrect.width())`), via `text::try_paint_from_layout_cache`.
|
||||
The walker computes `text_layout_cache_rotation_only` from `tree` and
|
||||
passes it into `render_shape`; stroke/shadow paths pass `false`.
|
||||
- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones
|
||||
(rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only.
|
||||
Decorations are skipped when no span requests underline/strike.
|
||||
- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring
|
||||
work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is
|
||||
compose+present only.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# CI (scripts/ci)
|
||||
|
||||
`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
|
||||
monorepo modules and prints a per-task summary. It is the local equivalent
|
||||
of CI; use it to verify changes before declaring work done.
|
||||
|
||||
## When to use
|
||||
|
||||
- After implementing or fixing code in a module: run its checks before
|
||||
finishing (AGENTS.md: run the applicable lint and format checks).
|
||||
- When `common/` changed: validate its consumers too (frontend, backend,
|
||||
exporter; see the dependency graph in `mem:critical-info`).
|
||||
- To fix formatting across a module (`--fix`) or repair delimiters
|
||||
(`--paren-repair`) before linting.
|
||||
|
||||
## How to use (CLI)
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/ci MODULE... # lint + test + fmt per module
|
||||
./scripts/ci --all --no-test # lint + fmt on all modules
|
||||
./scripts/ci --lint frontend # lint only
|
||||
./scripts/ci --fix --no-test frontend # format files, skip tests
|
||||
./scripts/ci --paren-repair --all # fix delimiters in all Clojure modules
|
||||
./scripts/ci --dry-run --all # preview what would run
|
||||
```
|
||||
|
||||
Modules: `frontend backend common render-wasm exporter mcp plugins library`.
|
||||
|
||||
Flags:
|
||||
|
||||
- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
|
||||
instead).
|
||||
- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
|
||||
`--no-test` / `--no-fmt` drop one task from the default set.
|
||||
- `--paren-repair` runs only the delimiter repair — it wraps
|
||||
`scripts/paren-repair` over each module's Clojure/CLJS sources; see
|
||||
`mem:scripts/paren-repair`.
|
||||
- `--all` selects every module; `--exclude MOD` drops one (repeatable).
|
||||
- `--fail-fast` stops at the first failure; `--quiet` suppresses failure
|
||||
output; `--dry-run` prints commands without running; `--clean` removes
|
||||
the log directory.
|
||||
|
||||
## Logs and exit codes
|
||||
|
||||
- Full output of every task: `.ci-logs/<module>-<task>.log`.
|
||||
- On failure the script prints the last 30 lines; the final summary lists
|
||||
every failed `module:task` with its log path.
|
||||
- Exit code 0 when all selected tasks passed, 1 otherwise.
|
||||
- Diagnose failures by reading the log file — never pipe test output
|
||||
through filters (AGENTS.md hard rule).
|
||||
|
||||
## Notes
|
||||
|
||||
- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
|
||||
`./test`, and `cargo fmt`.
|
||||
- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
|
||||
generous timeout when calling it from an agent shell.
|
||||
- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
|
||||
- Testing principles and output discipline: `mem:testing`.
|
||||
@@ -351,5 +351,5 @@ gh issue view <NUMBER> --repo penpot/penpot --json title
|
||||
## See Also
|
||||
|
||||
- End-to-end orchestration entry point: the `create-issue` skill at
|
||||
`.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
`.agents/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
point; this memory is the canonical home for all issue-creation rules.
|
||||
@@ -1,6 +1,12 @@
|
||||
# Creating Pull Requests
|
||||
|
||||
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
|
||||
PR only on explicit request.
|
||||
|
||||
## Branch Naming
|
||||
|
||||
- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`).
|
||||
- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
- If the user already created the branch, use it as-is — never rename.
|
||||
|
||||
## Target Branch
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Updating pnpm Across All Workspaces
|
||||
|
||||
Canonical procedure. Run it from the repo root with the log redirected to a
|
||||
file (never pipe tool output through filters).
|
||||
|
||||
## Layout facts
|
||||
|
||||
- The repo has 11 pnpm workspaces, each with its own `pnpm-workspace.yaml`
|
||||
and `pnpm-lock.yaml`: the repo root plus `backend`, `common`, `docs`,
|
||||
`exporter`, `frontend`, `library`, `mcp`, `media-processor`, `plugins`,
|
||||
and `render-wasm`.
|
||||
- Every package inside a module workspace (for example all `plugins/apps/*`
|
||||
and `plugins/libs/*` packages) is a plain member of that module's
|
||||
workspace. Members must not carry their own `pnpm-workspace.yaml` or
|
||||
`pnpm-lock.yaml`; their dependencies resolve through the parent
|
||||
workspace's lockfile.
|
||||
- One shared pnpm store for the whole repo: `<repo>/.pnpm-store`. Every
|
||||
workspace yaml sets it explicitly: `storeDir: .pnpm-store` at the root,
|
||||
`storeDir: ../.pnpm-store` in each module. pnpm resolves the value
|
||||
against the workspace root, so all workspaces land on the same store.
|
||||
Do not remove these lines: nested workspaces do not inherit settings,
|
||||
and without them each workspace may resolve a different store.
|
||||
- The store survives `node_modules` cleans. It is content-addressed and
|
||||
integrity-verified, so it cannot go stale; staleness lives in
|
||||
node_modules. Only `scripts/clean-node-modules --store` removes it.
|
||||
- Every `package.json` (about 35 of them) must carry a `packageManager` field
|
||||
with the identical `pnpm@<version>+sha512.<hash>` value. Do not let them drift.
|
||||
- CI pins no pnpm version; workflows rely on corepack reading
|
||||
`packageManager`. Fixing the fields fixes CI.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Resolve the target tag first and note the version. Example:
|
||||
`npm view pnpm dist-tags --json` for `next-12` (latest 12.x). The tag
|
||||
moves over time; always re-check.
|
||||
2. List every directory with a `package.json`, excluding `node_modules`
|
||||
(`fd -H -t f package.json -E node_modules`). This list is the work set;
|
||||
do not maintain a hand-written list.
|
||||
3. Run `corepack use pnpm@<tag>` in workspace roots first, then members.
|
||||
`corepack use` stamps `packageManager` in the nearest package.json and
|
||||
runs an install. Member runs repeat the workspace install; after the root
|
||||
run they are quick no-ops.
|
||||
4. If a run fails, fix the cause (see gotchas) and re-run that directory.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `corepack use` only updates an existing `packageManager` field. If a
|
||||
package.json lacks the field, corepack walks up to the nearest ancestor
|
||||
that has one and stamps that file instead; the member stays unstamped.
|
||||
After the sweep, assert every package.json carries the field. For a
|
||||
missing one, insert the identical `pnpm@<version>+sha512.<hash>` string,
|
||||
then re-run `corepack use pnpm@<tag>` in that directory.
|
||||
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
|
||||
a placeholder scaffold into its `pnpm-workspace.yaml`:
|
||||
`allowBuilds: esbuild: set this to true or false` plus
|
||||
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
|
||||
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
|
||||
then re-run.
|
||||
- `plugins/apps/composable-test-suite` once had its own
|
||||
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
|
||||
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
|
||||
so a nested one silently forks install and lockfile behavior. Do not
|
||||
reintroduce it.
|
||||
- Expect metadata-only lockfile diffs when only the pnpm version moves:
|
||||
the pnpm self-reference entries, plus a new `packageManagerDependencies`
|
||||
section in lockfiles last written by older pnpm. Large diffs mean
|
||||
re-resolution; inspect them before accepting.
|
||||
|
||||
## Verification
|
||||
|
||||
- Every `packageManager` field is byte-identical (same version and hash).
|
||||
- `pnpm --version` in each workspace prints the target version.
|
||||
- `pnpm install --frozen-lockfile` succeeds in each of the 11 workspaces.
|
||||
- `git diff` on lockfiles matches the expectations above.
|
||||
|
||||
## Cleaning stale node_modules
|
||||
|
||||
- `scripts/clean-node-modules` removes every workspace `node_modules`: the
|
||||
repo root, all module workspaces, and all member packages. Use it when
|
||||
installs misbehave after dependency changes: clean, reinstall, done.
|
||||
- Flags: `-n/--dry-run` lists without deleting; `--store` also removes the
|
||||
shared pnpm store at `<repo>/.pnpm-store` (the next install re-downloads
|
||||
what it held). `external/` (vendored dependency trees with their own
|
||||
lifecycles) and `.opencode/` are always ignored.
|
||||
- The script never touches the pnpm store by default, so the reinstall
|
||||
after cleaning reuses cached packages (zero downloads).
|
||||
- After cleaning, run `pnpm install` in each workspace root to restore the
|
||||
development environment; `frontend` postinstall also reinstalls and
|
||||
builds `plugins-runtime`.
|
||||
@@ -8,6 +8,9 @@
|
||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||
If the user pushes, treat that commit as final from the agent's side.
|
||||
- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is
|
||||
generated from GitHub milestones during the release process; update it only
|
||||
via the `update-changelog` skill flow or on explicit user request.
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
@@ -142,6 +145,6 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
|
||||
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
|
||||
|
||||
+34
-1
@@ -2,6 +2,12 @@
|
||||
|
||||
## 2.19.0 (Unreleased)
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
|
||||
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
|
||||
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305))
|
||||
@@ -11,6 +17,20 @@
|
||||
- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454))
|
||||
- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156))
|
||||
- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199))
|
||||
- Fix "Cannot assign to read only property 'toString'" error during text resize (by @makesomethingshit) [#10168](https://github.com/penpot/penpot/issues/10168) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
|
||||
- Fix plugin postMessage channel broadcasting messages to all plugins without origin validation [#10968](https://github.com/penpot/penpot/issues/10968) (PR: [#10970](https://github.com/penpot/penpot/pull/10970))
|
||||
- Fix MCP plugin page navigation while connected crashing the workspace (by @makesomethingshit) [#11001](https://github.com/penpot/penpot/issues/11001) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
|
||||
- Fix shortcut search never matching on key combination, only on action label [#11003](https://github.com/penpot/penpot/issues/11003) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
|
||||
- Fix Shift + special character key shortcut capturing the shifted character instead of the physical key [#11004](https://github.com/penpot/penpot/issues/11004) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
|
||||
- Fix reassigning the "Paste" shortcut not updating the UI or taking effect in the workspace [#11005](https://github.com/penpot/penpot/issues/11005) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
|
||||
- Fix font-size dropdown clipping multi-digit values in Firefox (by @0xTHAC0) [#11008](https://github.com/penpot/penpot/issues/11008) (PR: [#11162](https://github.com/penpot/penpot/pull/11162), [#11500](https://github.com/penpot/penpot/pull/11500))
|
||||
- Fix exporting shortcuts producing an invalid "toggle-fullscreen" entry that breaks re-import [#11032](https://github.com/penpot/penpot/issues/11032) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
|
||||
- Fix plugin API missing permission checks in tokens, shapes, variants, flows, layouts, and user identity [#11137](https://github.com/penpot/penpot/issues/11137) (PR: [#11139](https://github.com/penpot/penpot/pull/11139))
|
||||
- Fix library summary Redis cache keys omitting the tenant [#11407](https://github.com/penpot/penpot/issues/11407) (PR: [#11408](https://github.com/penpot/penpot/pull/11408))
|
||||
- Fix active theme name in the inspect tab displaying an id instead of the name [#11437](https://github.com/penpot/penpot/issues/11437) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
|
||||
- Fix triple-click not selecting the full line in text editor v3 [#11483](https://github.com/penpot/penpot/issues/11483) (PR: [#11493](https://github.com/penpot/penpot/pull/11493))
|
||||
- Fix pasted text losing formatting on last lines after resizing and adding new lines from the top [#11501](https://github.com/penpot/penpot/issues/11501) (PR: [#11503](https://github.com/penpot/penpot/pull/11503))
|
||||
- Fix variant property dropdown appearing empty and throwing an internal error when the component has no sibling variants [#11524](https://github.com/penpot/penpot/issues/11524) (PR: [#11499](https://github.com/penpot/penpot/pull/11499))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -19,9 +39,22 @@
|
||||
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
|
||||
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
|
||||
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
|
||||
- Add support for internal libraries and file sync for Design Tokens [#9334](https://github.com/penpot/penpot/issues/9334)
|
||||
- Warn self-hosted users when their Penpot version is outdated and surface what they're missing [#10497](https://github.com/penpot/penpot/issues/10497) (PR: [#11411](https://github.com/penpot/penpot/pull/11411))
|
||||
- Add dedicated RPC methods for plugin registry operations with permission validation [#10952](https://github.com/penpot/penpot/issues/10952) (PR: [#10957](https://github.com/penpot/penpot/pull/10957))
|
||||
- Document MCP and internal resolver environment variables (by @ShreyashAgare26) [#11318](https://github.com/penpot/penpot/issues/11318) (PR: [#11572](https://github.com/penpot/penpot/pull/11572))
|
||||
- Add tokens source indicator to assets tab [#11365](https://github.com/penpot/penpot/issues/11365) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
|
||||
- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467))
|
||||
- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565))
|
||||
|
||||
## 2.18.0 (Unreleased)
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
|
||||
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
|
||||
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
|
||||
@@ -360,7 +393,7 @@
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
|
||||
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR: [#9113](https://github.com/penpot/penpot/pull/9113))
|
||||
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Read and follow the instructions in `AGENTS.md`.
|
||||
|
||||
Treat `AGENTS.md` as the canonical project instruction file.
|
||||
@@ -14,6 +14,7 @@ Center](https://help.penpot.app/).
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Format](#format)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
@@ -73,6 +74,18 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Branch names are not enforced, but we recommend the following:
|
||||
|
||||
- **`issue-NNNN`** — when working from a GitHub issue, name the branch after
|
||||
it (e.g. `issue-11525`). This makes each PR's origin self-evident.
|
||||
- Otherwise, use a short, descriptive name with words separated by hyphens
|
||||
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
|
||||
Since PRs are squash-merged, the branch name does not survive into the
|
||||
commit history — what matters is the [PR title](#title-format).
|
||||
|
||||
### Format
|
||||
|
||||
#### Title
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
|
||||
"packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
|
||||
Generated
+101
@@ -1,3 +1,104 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
pnpm:
|
||||
specifier: 12.3.4
|
||||
version: 12.3.4
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.3.4':
|
||||
resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.3.4':
|
||||
resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.3.4':
|
||||
resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-x64@12.3.4':
|
||||
resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/exe.win32-x64@12.3.4':
|
||||
resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
pnpm@12.3.4:
|
||||
resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
|
||||
engines: {node: '>=18.*'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
pnpm@12.3.4:
|
||||
optionalDependencies:
|
||||
'@pnpm/exe.darwin-arm64': 12.3.4
|
||||
'@pnpm/exe.darwin-x64': 12.3.4
|
||||
'@pnpm/exe.linux-arm64': 12.3.4
|
||||
'@pnpm/exe.linux-arm64-musl': 12.3.4
|
||||
'@pnpm/exe.linux-x64': 12.3.4
|
||||
'@pnpm/exe.linux-x64-musl': 12.3.4
|
||||
'@pnpm/exe.win32-arm64': 12.3.4
|
||||
'@pnpm/exe.win32-x64': 12.3.4
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
storeDir: ../.pnpm-store
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@5.0.8 || 5.0.9
|
||||
@@ -1037,7 +1037,7 @@
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
_info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:minutes 15})]
|
||||
exp (ct/in-future {:hours 4})]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
|
||||
@@ -467,25 +467,26 @@
|
||||
Raises :validation :max-file-size-reached when the limit is exceeded."
|
||||
^InputStream
|
||||
[^InputStream input ^long max-size]
|
||||
(let [counter (atom 0)]
|
||||
(let [counter (atom 0)
|
||||
on-read (fn [n]
|
||||
(when (pos? n)
|
||||
(when (> (swap! counter + (long n)) max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "stream exceeded max size: " max-size))))
|
||||
n)]
|
||||
(proxy [FilterInputStream] [input]
|
||||
(read
|
||||
([]
|
||||
(let [b (.read input)]
|
||||
(when (pos? b)
|
||||
(when (> (swap! counter inc) max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "stream exceeded max size: " max-size))))
|
||||
(when (pos? b) (on-read 1))
|
||||
b))
|
||||
([buf off len]
|
||||
(let [n (.read input buf off len)]
|
||||
(when (pos? n)
|
||||
(when (> (swap! counter + (long n)) max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "stream exceeded max size: " max-size))))
|
||||
n))))))
|
||||
([^bytes buf]
|
||||
(on-read (.read input buf 0 (alength buf))))
|
||||
([^bytes buf off]
|
||||
(on-read (.read input buf (int off) (- (alength buf) (int off)))))
|
||||
([^bytes buf off len]
|
||||
(on-read (.read input buf (int off) (int len))))))))
|
||||
|
||||
(defn- zip-entry-reader
|
||||
[^ZipFile input ^ZipEntry entry]
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
:objects-storage-fs-directory "assets"
|
||||
|
||||
:auth-token-cookie-name "auth-token"
|
||||
:auth-token-cookie-max-age-absolute (ct/duration {:days 30})
|
||||
|
||||
:assets-path "/internal/assets/"
|
||||
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
||||
@@ -206,6 +207,7 @@
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||
[:auth-token-cookie-max-age-absolute {:optional true} ::ct/duration]
|
||||
|
||||
[:registration-domain-whitelist {:optional true} [::sm/set :string]]
|
||||
[:email-verify-threshold {:optional true} ::ct/duration]
|
||||
|
||||
@@ -366,10 +366,14 @@
|
||||
(if clone?
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
team (teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)
|
||||
cfg (assoc cfg
|
||||
::bfc/overwrite false
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/input path
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
@@ -628,6 +632,7 @@
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/features (cfeat/get-team-enabled-features cf/flags team)
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
;; Default age for automatic session renewal
|
||||
(def default-renewal-max-age (ct/duration {:hours 6}))
|
||||
|
||||
;; Default absolute maximum session duration
|
||||
(def default-cookie-max-age-absolute (ct/duration {:days 30}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PROTOCOLS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -169,15 +172,19 @@
|
||||
|
||||
(defn- assign-token
|
||||
[cfg session]
|
||||
(let [claims {:iss "authentication"
|
||||
:aud "penpot"
|
||||
:sid (:id session)
|
||||
:iat (:modified-at session)
|
||||
:uid (:profile-id session)
|
||||
:sso-provider-id (:sso-provider-id session)
|
||||
:sso-session-id (:sso-session-id session)}
|
||||
header {:kid 1 :ver 1}
|
||||
token (tokens/generate cfg claims header)]
|
||||
(let [absolute-max-age (cf/get :auth-token-cookie-max-age-absolute default-cookie-max-age-absolute)
|
||||
claims {:iss "authentication"
|
||||
:aud "penpot"
|
||||
:sid (:id session)
|
||||
:iat (:modified-at session)
|
||||
:uid (:profile-id session)
|
||||
:sso-provider-id (:sso-provider-id session)
|
||||
:sso-session-id (:sso-session-id session)}
|
||||
claims (if (:created-at session)
|
||||
(assoc claims :exp (ct/plus (:created-at session) absolute-max-age))
|
||||
claims)
|
||||
header {:kid 1 :ver 1}
|
||||
token (tokens/generate cfg claims header)]
|
||||
(assoc session :token token)))
|
||||
|
||||
(defn create-fn
|
||||
@@ -353,15 +360,23 @@
|
||||
or (updated_at is null and
|
||||
created_at < ?::timestamptz)")
|
||||
|
||||
(def ^:private
|
||||
sql:delete-expired-v2
|
||||
"DELETE FROM http_session_v2
|
||||
WHERE created_at < ?::timestamptz")
|
||||
|
||||
(defn- collect-expired-tasks
|
||||
[{:keys [::db/conn ::tasks/max-age]}]
|
||||
(let [threshold (ct/minus (ct/now) max-age)
|
||||
result (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
|
||||
(db/get-update-count))]
|
||||
result-legacy (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
|
||||
(db/get-update-count))
|
||||
result-v2 (-> (db/exec-one! conn [sql:delete-expired-v2 threshold])
|
||||
(db/get-update-count))]
|
||||
(l/dbg :task "gc"
|
||||
:hint "clean http sessions"
|
||||
:deleted result)
|
||||
result))
|
||||
:deleted-legacy result-legacy
|
||||
:deleted-v2 result-v2)
|
||||
(+ result-legacy result-v2)))
|
||||
|
||||
(defmethod ig/init-key ::tasks/gc
|
||||
[_ {:keys [::tasks/max-age] :as cfg}]
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.nitrate
|
||||
'app.rpc.commands.plugins
|
||||
'app.rpc.commands.profile
|
||||
'app.rpc.commands.projects
|
||||
'app.rpc.commands.search
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
(:require
|
||||
[app.auth :refer [derive-password-weak]]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -21,14 +22,21 @@
|
||||
[buddy.core.codecs :as bc]
|
||||
[buddy.core.nonce :as bn]))
|
||||
|
||||
(def ^:private
|
||||
schema:create-demo-profile
|
||||
[:map
|
||||
[:skip-onboarding {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::create-demo-profile
|
||||
"A command that is responsible of creating a demo purpose
|
||||
profile. It only works if the `demo-users` flag is enabled in the
|
||||
configuration."
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"
|
||||
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
|
||||
[cfg _]
|
||||
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
|
||||
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]]
|
||||
::sm/params schema:create-demo-profile}
|
||||
[cfg {:keys [skip-onboarding]}]
|
||||
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
@@ -48,7 +56,13 @@
|
||||
:is-active true
|
||||
:is-demo true
|
||||
:password (derive-password-weak password)
|
||||
:props {}}
|
||||
:props (cond-> {}
|
||||
skip-onboarding (assoc :onboarding-viewed true
|
||||
;; Redundant today: auth/create-profile
|
||||
;; overwrites this with the current
|
||||
;; version, kept so the skip does not
|
||||
;; depend on that default.
|
||||
:release-notes-viewed (:main cf/version)))}
|
||||
profile (db/tx-run! cfg (fn [cfg]
|
||||
(->> (auth/create-profile cfg params)
|
||||
(auth/create-profile-rels cfg))))]
|
||||
|
||||
@@ -241,6 +241,18 @@
|
||||
(some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"})
|
||||
(update :data blob/decode)))
|
||||
|
||||
(defn- check-fragment-scope!
|
||||
"Checks that the fragment is reachable from the pages authorized by
|
||||
the share-link. Raises a :not-found exception if the fragment is not reachable."
|
||||
[cfg file-id fragment-id pages]
|
||||
(let [fdata (-> (bfc/get-file cfg file-id :read-only? true)
|
||||
(get :data)
|
||||
(update :pages-index select-keys pages))]
|
||||
(when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "object not found"))))
|
||||
|
||||
(sv/defmethod ::get-file-fragment
|
||||
"Retrieve a file fragment by its ID. Only authenticated users."
|
||||
{::doc/added "1.17"
|
||||
@@ -250,11 +262,9 @@
|
||||
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(when (= :share-link (:type perms))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "object not found"))
|
||||
(check-read-permissions! perms)
|
||||
(when (= :share-link (:type perms))
|
||||
(check-fragment-scope! cfg file-id fragment-id (:pages perms)))
|
||||
(-> (get-file-fragment cfg file-id fragment-id)
|
||||
(rph/with-http-cache long-cache-duration))))))
|
||||
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
cfg (-> cfg
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/team-id (:id team))
|
||||
(assoc ::bfc/input template)
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.rpc.commands.plugins
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(defn- validate-plugin-permissions!
|
||||
"Validates that all permissions in the plugin are within the valid set."
|
||||
[plugin]
|
||||
(let [permissions (:permissions plugin)
|
||||
invalid (remove ctp/valid-permissions permissions)]
|
||||
(when (seq invalid)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-plugin-permissions
|
||||
:hint (str "Invalid permissions: " (pr-str (set invalid)))
|
||||
:invalid-permissions (set invalid)))))
|
||||
|
||||
(def ^:private
|
||||
schema:add-profile-plugin
|
||||
[:map {:title "add-profile-plugin"}
|
||||
[:plugin ctp/schema:registry-entry]])
|
||||
|
||||
(sv/defmethod ::add-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:add-profile-plugin
|
||||
::sm/result ctp/schema:registry-entry
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin]}]
|
||||
(validate-plugin-permissions! plugin)
|
||||
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id (:plugin-id plugin)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (distinct (conj % plugin-id))))
|
||||
(assoc-in [:data plugin-id] plugin))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
plugin))
|
||||
|
||||
(def ^:private
|
||||
schema:remove-profile-plugin
|
||||
[:map {:title "remove-profile-plugin"}
|
||||
[:plugin-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::remove-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:remove-profile-plugin
|
||||
::sm/result :nil
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
|
||||
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
|
||||
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
|
||||
plugin-id-str (str plugin-id)
|
||||
plugins (-> plugins
|
||||
(update :ids #(vec (remove (partial = plugin-id-str) %)))
|
||||
(update :data dissoc plugin-id-str))]
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
nil))
|
||||
@@ -12,7 +12,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.plugins :refer [schema:plugin-registry]]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
(def system-managed-props
|
||||
"Props keys managed by the system (not user-writable via RPC)."
|
||||
#{:subscription})
|
||||
#{:subscription :plugins})
|
||||
|
||||
(def schema:props
|
||||
[:map {:title "ProfileProps" :closed true}
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:plugins {:optional true} ctp/schema:plugin-registry]
|
||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
@@ -78,6 +78,10 @@
|
||||
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
|
||||
[:nudge {:optional true} schema:nudge]])
|
||||
|
||||
(def schema:props-writeable
|
||||
"Props schema for user-writable fields (excludes system-managed keys)."
|
||||
(reduce sm/dissoc-key schema:props system-managed-props))
|
||||
|
||||
(def schema:profile
|
||||
[:map {:title "Profile"}
|
||||
[:id ::sm/uuid]
|
||||
@@ -463,7 +467,7 @@
|
||||
(def ^:private
|
||||
schema:update-profile-props
|
||||
[:map {:title "update-profile-props"}
|
||||
[:props schema:props]])
|
||||
[:props schema:props-writeable]])
|
||||
|
||||
(defn update-profile-props
|
||||
[{:keys [::db/conn] :as cfg} profile-id props]
|
||||
|
||||
@@ -235,9 +235,8 @@
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
organization
|
||||
(assoc :user-who-send-invitation (str profile-id))
|
||||
:role role
|
||||
:user-who-send-invitation (str profile-id)}
|
||||
|
||||
(not organization)
|
||||
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||
|
||||
@@ -308,7 +308,9 @@
|
||||
(assoc :name "accept-organization-invitation")
|
||||
(assoc :props
|
||||
(-> props
|
||||
(assoc :organization-id organization-id-on-add)
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation))
|
||||
(audit/clean-props))))))
|
||||
|
||||
(cond-> (assoc claims :state :created)
|
||||
@@ -325,6 +327,8 @@
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:organization-member-add-source organization-add-source
|
||||
:belongs-to-team-on-add (boolean team-id)
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation)
|
||||
:organization-member-count-before
|
||||
organization-member-count-before)
|
||||
(audit/clean-props))}))))))
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
[app.http.client :as http]
|
||||
[app.main :as-alias main]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.json :as json]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]))
|
||||
@@ -248,20 +247,16 @@
|
||||
:props (or (some-> props db/decode-transit-pgobject) {})
|
||||
:context (or (some-> context db/decode-transit-pgobject) {})}))
|
||||
|
||||
(defn- encode-batch
|
||||
"Encode a sequence of event maps into a fressian+zstd base64 string
|
||||
suitable for JSON transport."
|
||||
^String [events]
|
||||
(blob/encode-str events {:version 4}))
|
||||
|
||||
(defn send-event-batch
|
||||
"Send a single batch of events to the telemetry endpoint. Returns
|
||||
true on success."
|
||||
true on success. The events are sent as a plain vector of event
|
||||
maps; the JSON encoder handles UUID and temporal types natively and
|
||||
the receiver coerces them back to proper types."
|
||||
[{:keys [::setup/props] :as cfg} batch]
|
||||
(let [payload {:type :telemetry-events
|
||||
:version (:full cf/version)
|
||||
:instance-id (:instance-id props)
|
||||
:events (encode-batch batch)}
|
||||
:events (vec batch)}
|
||||
request {:method :post
|
||||
:uri (cf/get :telemetry-uri)
|
||||
:headers {"content-type" "application/json"}
|
||||
|
||||
@@ -277,6 +277,70 @@
|
||||
(t/is (= (:id session) (:sid claims)))
|
||||
(t/is (= (:id profile) (:uid claims)))))
|
||||
|
||||
(t/deftest session-token-contains-exp-claim
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
exp (:exp claims)]
|
||||
(t/is (some? exp) "session token should contain :exp claim")
|
||||
(t/is (ct/inst? exp) "exp should be an instant")))
|
||||
|
||||
(t/deftest session-token-exp-based-on-created-at
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))]
|
||||
(t/is (some? (:exp claims)) "session token should contain :exp claim")
|
||||
(t/is (= (inst-ms (:exp claims))
|
||||
(inst-ms expected-exp))
|
||||
"exp should equal created-at + 30 days")))
|
||||
|
||||
(t/deftest session-token-past-exp-is-rejected
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
;; Manually create a token with exp in the past
|
||||
past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1})))
|
||||
header {:kid 1 :ver 1}
|
||||
past-token (tokens/generate cfg past-claims header)]
|
||||
(t/is (nil? (session/decode-token cfg past-token))
|
||||
"token with exp in the past should be rejected")))
|
||||
|
||||
(t/deftest session-renewal-preserves-original-exp
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
handler (-> (fn [req] req)
|
||||
(#'session/wrap-authz {::session/manager manager})
|
||||
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)}))
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
original-exp (:exp (tokens/decode cfg (:token session)))
|
||||
;; Force renewal by setting modified-at to 7 hours ago
|
||||
old-session (assoc session :modified-at (ct/minus (ct/now) (ct/duration {:hours 7})))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" (:token old-session)}}))
|
||||
{:keys [token claims]} (get response ::http/auth-data)
|
||||
new-exp (:exp claims)]
|
||||
(t/is (some? original-exp) "original token should have :exp")
|
||||
(t/is (some? new-exp) "renewed token should have :exp")
|
||||
(t/is (= (inst-ms original-exp)
|
||||
(inst-ms new-exp))
|
||||
"renewed token should preserve original :exp, not extend it")))
|
||||
|
||||
(t/deftest parse-request-illegal-argument-exception
|
||||
;; clojure.data.json raises IllegalArgumentException (case
|
||||
;; fall-through) on several kinds of malformed input. The
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.config :as cf]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
@@ -38,3 +39,38 @@
|
||||
(with-redefs [cf/flags (disj cf/flags :demo-users)]
|
||||
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile})]
|
||||
(t/is (th/ex-of-code? error :demo-users-not-allowed)))))
|
||||
|
||||
(t/deftest create-demo-profile-keeps-onboarding-by-default
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-skips-onboarding-when-requested
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding true})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (true? (get-in decoded [:props :onboarding-viewed])))
|
||||
(t/is (= (:main cf/version)
|
||||
(get-in decoded [:props :release-notes-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-explicit-false-keeps-onboarding
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding false})]
|
||||
(t/is (nil? error))
|
||||
(let [saved (th/db-get :profile {:email (:email result)})
|
||||
decoded (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
|
||||
|
||||
(t/deftest create-demo-profile-rejects-non-boolean-skip-onboarding
|
||||
(with-redefs [cf/flags (conj cf/flags :demo-users)]
|
||||
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
|
||||
:skip-onboarding "yes"})]
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :params-validation)))))
|
||||
@@ -0,0 +1,162 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-plugins-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private plugin-id-1 (str (uuid/next)))
|
||||
(def ^:private plugin-id-2 (str (uuid/next)))
|
||||
|
||||
(def ^:private valid-plugin
|
||||
{:plugin-id plugin-id-1
|
||||
:name "Test Plugin"
|
||||
:description "A test plugin"
|
||||
:host "https://example.com"
|
||||
:code "(function() { console.log('hello'); })()"
|
||||
:icon "icon.svg"
|
||||
:permissions #{"content:read" "content:write"}})
|
||||
|
||||
(t/deftest add-profile-plugin-accepts-valid-permissions
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? (:result out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= [plugin-id-1] (:ids plugins)))
|
||||
(t/is (= valid-plugin (get-in plugins [:data plugin-id-1]))))))
|
||||
|
||||
(t/deftest add-profile-plugin-rejects-invalid-permissions
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"})
|
||||
data {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin}
|
||||
out (th/command! data)]
|
||||
|
||||
;; Schema validation catches invalid permissions before custom validation
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (nil? plugins) "No plugins should be persisted when validation fails"))))
|
||||
|
||||
(t/deftest add-profile-plugin-updates-existing-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
_ (th/command! data1)
|
||||
|
||||
updated-plugin (assoc valid-plugin :name "Updated Plugin")
|
||||
data2 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin updated-plugin}
|
||||
out (th/command! data2)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= 1 (count (:ids plugins))) "Should still have only one plugin")
|
||||
(t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name]))))))
|
||||
|
||||
(t/deftest remove-profile-plugin-removes-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin valid-plugin}
|
||||
_ (th/command! data1)
|
||||
|
||||
data2 {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/uuid plugin-id-1)}
|
||||
out (th/command! data2)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= [] (:ids plugins)))
|
||||
(t/is (empty? (:data plugins))))))
|
||||
|
||||
(t/deftest remove-profile-plugin-handles-nonexistent-plugin
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :remove-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin-id (uuid/next)}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (or (nil? plugins)
|
||||
(and (empty? (:ids plugins))
|
||||
(empty? (:data plugins))))
|
||||
"Plugins should be nil or empty when no plugins exist"))))
|
||||
|
||||
(t/deftest add-profile-plugin-multiple-plugins
|
||||
(let [profile (th/create-profile* 1)
|
||||
plugin1 valid-plugin
|
||||
plugin2 (assoc valid-plugin
|
||||
:plugin-id plugin-id-2
|
||||
:name "Second Plugin")
|
||||
|
||||
data1 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin1}
|
||||
_ (th/command! data1)
|
||||
|
||||
data2 {::th/type :add-profile-plugin
|
||||
::rpc/profile-id (:id profile)
|
||||
:plugin plugin2}
|
||||
_ (th/command! data2)]
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)
|
||||
plugins (get-in props [:props :plugins])]
|
||||
(t/is (= 2 (count (:ids plugins))))
|
||||
(t/is (contains? (set (:ids plugins)) plugin-id-1))
|
||||
(t/is (contains? (set (:ids plugins)) plugin-id-2))
|
||||
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
|
||||
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
|
||||
|
||||
(t/deftest update-profile-props-rejects-plugins
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))
|
||||
|
||||
(let [saved (th/db-get :profile {:id (:id profile)})
|
||||
props (profile/decode-row saved)]
|
||||
(t/is (nil? (get-in props [:props :plugins]))
|
||||
":plugins must not be writable via update-profile-props"))))
|
||||
@@ -154,10 +154,14 @@
|
||||
(get-in % [:props :member-email])))
|
||||
events))]
|
||||
(doseq [event [create-organization update-organization]]
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
|
||||
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
|
||||
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
|
||||
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in create-plain [:props :user-who-send-invitation])))
|
||||
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
|
||||
@@ -521,6 +525,9 @@
|
||||
|
||||
(let [event (organization-event)]
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (= (:id invitee) (get-in event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
@@ -530,6 +537,10 @@
|
||||
(:origin @frontend-event)))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in @frontend-event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in @frontend-event [:props :user-who-send-invitation])))
|
||||
(t/is (= "direct-organization-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
@@ -570,6 +581,9 @@
|
||||
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
|
||||
(t/is (= (:id team) (get-in event [:props :team-id])))
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (= (:id invitee) (get-in event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
@@ -578,6 +592,10 @@
|
||||
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in @frontend-event [:props :user-id])))
|
||||
(t/is (= (:id inviter)
|
||||
(get-in @frontend-event [:props :user-who-send-invitation])))
|
||||
(t/is (= "team-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.tasks.telemetry :as telemetry]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.json :as json]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
@@ -59,11 +58,6 @@
|
||||
:cnt
|
||||
long))
|
||||
|
||||
(defn- decode-event-batch
|
||||
"Decode the base64+fressian+zstd event-batch sent to the mock."
|
||||
[b64-str]
|
||||
(blob/decode-str b64-str))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -245,21 +239,19 @@
|
||||
(t/is (not (contains? ev :ip-addr)))))))))
|
||||
|
||||
(t/deftest test-batch-encoding-is-decodable
|
||||
;; Verify that encode-batch produces a blob that round-trips back
|
||||
;; through blob/decode to the original data.
|
||||
;; Events are sent as a plain vector of raw event maps (no blob
|
||||
;; encoding): every batch must JSON round-trip unchanged, because
|
||||
;; the receiver coerces types from the plain JSON representation.
|
||||
(let [events [{:name "navigate" :type "action" :source "telemetry"
|
||||
:tracked-at (ct/now)}
|
||||
{:name "create-file" :type "action" :source "telemetry"
|
||||
:tracked-at (ct/now)}]
|
||||
;; Call the private fn through the ns-mapped var
|
||||
encode (ns-resolve 'app.tasks.telemetry 'encode-batch)
|
||||
encoded (encode events)
|
||||
decoded (decode-event-batch encoded)]
|
||||
(t/is (string? encoded))
|
||||
(t/is (seq decoded))
|
||||
(t/is (= (count events) (count decoded)))
|
||||
(t/is (= "navigate" (:name (first decoded))))
|
||||
(t/is (= "create-file" (:name (second decoded))))))
|
||||
encoded (json/encode-str {:events (vec events)})
|
||||
decoded (json/decode encoded)]
|
||||
(t/is (vector? (:events decoded)))
|
||||
(t/is (= (count events) (count (:events decoded))))
|
||||
(t/is (= "navigate" (:name (first (:events decoded)))))
|
||||
(t/is (= "create-file" (:name (second (:events decoded)))))))
|
||||
|
||||
(t/deftest test-multiple-batches-when-many-events
|
||||
;; Lower batch-size to 1 so that 3 events produce 3 separate
|
||||
@@ -787,9 +779,13 @@
|
||||
(t/is (= "telemetry-events" (name (:type body))))
|
||||
(t/is (string? (:version body)))
|
||||
(t/is (some? (:instance-id body)))
|
||||
;; :events is a base64-encoded blob
|
||||
(t/is (string? (:events body)))
|
||||
(t/is (pos? (count (:events body))))))))))
|
||||
;; :events is a plain vector of raw event maps
|
||||
(t/is (vector? (:events body)))
|
||||
(t/is (pos? (count (:events body))))
|
||||
(doseq [ev (:events body)]
|
||||
(t/is (string? (:name ev)))
|
||||
(t/is (string? (:source ev)))
|
||||
(t/is (string? (:tracked-at ev))))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TASK BRANCH COVERAGE
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
|
||||
"packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+37
-37
@@ -7,96 +7,96 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
pnpm:
|
||||
specifier: 12.0.0
|
||||
version: 12.0.0
|
||||
specifier: 12.3.4
|
||||
version: 12.3.4
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.0.0':
|
||||
resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
|
||||
'@pnpm/exe.darwin-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.0.0':
|
||||
resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
|
||||
'@pnpm/exe.darwin-x64@12.3.4':
|
||||
resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.0.0':
|
||||
resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
|
||||
'@pnpm/exe.linux-arm64-musl@12.3.4':
|
||||
resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.0.0':
|
||||
resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
|
||||
'@pnpm/exe.linux-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.0.0':
|
||||
resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
|
||||
'@pnpm/exe.linux-x64-musl@12.3.4':
|
||||
resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-x64@12.0.0':
|
||||
resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
|
||||
'@pnpm/exe.linux-x64@12.3.4':
|
||||
resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.0.0':
|
||||
resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
|
||||
'@pnpm/exe.win32-arm64@12.3.4':
|
||||
resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/exe.win32-x64@12.0.0':
|
||||
resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
|
||||
'@pnpm/exe.win32-x64@12.3.4':
|
||||
resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
pnpm@12.0.0:
|
||||
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
|
||||
pnpm@12.3.4:
|
||||
resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
|
||||
engines: {node: '>=18.*'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.0.0':
|
||||
'@pnpm/exe.darwin-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.0.0':
|
||||
'@pnpm/exe.darwin-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.0.0':
|
||||
'@pnpm/exe.linux-arm64-musl@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.0.0':
|
||||
'@pnpm/exe.linux-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.0.0':
|
||||
'@pnpm/exe.linux-x64-musl@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64@12.0.0':
|
||||
'@pnpm/exe.linux-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.0.0':
|
||||
'@pnpm/exe.win32-arm64@12.3.4':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-x64@12.0.0':
|
||||
'@pnpm/exe.win32-x64@12.3.4':
|
||||
optional: true
|
||||
|
||||
pnpm@12.0.0:
|
||||
pnpm@12.3.4:
|
||||
optionalDependencies:
|
||||
'@pnpm/exe.darwin-arm64': 12.0.0
|
||||
'@pnpm/exe.darwin-x64': 12.0.0
|
||||
'@pnpm/exe.linux-arm64': 12.0.0
|
||||
'@pnpm/exe.linux-arm64-musl': 12.0.0
|
||||
'@pnpm/exe.linux-x64': 12.0.0
|
||||
'@pnpm/exe.linux-x64-musl': 12.0.0
|
||||
'@pnpm/exe.win32-arm64': 12.0.0
|
||||
'@pnpm/exe.win32-x64': 12.0.0
|
||||
'@pnpm/exe.darwin-arm64': 12.3.4
|
||||
'@pnpm/exe.darwin-x64': 12.3.4
|
||||
'@pnpm/exe.linux-arm64': 12.3.4
|
||||
'@pnpm/exe.linux-arm64-musl': 12.3.4
|
||||
'@pnpm/exe.linux-x64': 12.3.4
|
||||
'@pnpm/exe.linux-x64-musl': 12.3.4
|
||||
'@pnpm/exe.win32-arm64': 12.3.4
|
||||
'@pnpm/exe.win32-x64': 12.3.4
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
storeDir: ../.pnpm-store
|
||||
|
||||
minimumReleaseAge: 0
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.common.files.changes
|
||||
(:require
|
||||
#?(:cljs [app.common.files.validate :as val])
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
@@ -428,7 +429,14 @@
|
||||
[:set-base-font-size
|
||||
[:map {:title "ModBaseFontSize"}
|
||||
[:type [:= :set-base-font-size]]
|
||||
[:base-font-size :string]]]])
|
||||
[:base-font-size :string]]]
|
||||
|
||||
[:validate-shapes
|
||||
[:map {:title "ValidateShapesChange"}
|
||||
[:type [:= :validate-shapes]]
|
||||
[:page-id ::sm/uuid]
|
||||
[:shape-ids [:vector ::sm/uuid]]
|
||||
[:context :string]]]])
|
||||
|
||||
(def schema:changes
|
||||
[:sequential {:gen/max 5 :gen/min 1} schema:change])
|
||||
@@ -464,7 +472,7 @@
|
||||
to the processor backend."
|
||||
nil)
|
||||
|
||||
(defmulti process-change (fn [_ change] (:type change)))
|
||||
(defmulti process-change (fn [_ change _] (:type change)))
|
||||
(defmulti process-operation (fn [_ op] (:type op)))
|
||||
|
||||
;; Changes Processing Impl
|
||||
@@ -496,22 +504,25 @@
|
||||
|
||||
(defn process-changes
|
||||
([data items]
|
||||
(process-changes data items true))
|
||||
(process-changes data items true {}))
|
||||
|
||||
([data items verify?]
|
||||
(process-changes data items verify? {}))
|
||||
|
||||
([data items verify? libraries]
|
||||
;; When verify? false we spec the schema validation. Currently used
|
||||
;; to make just 1 validation even if the changes are applied twice
|
||||
(when verify?
|
||||
(check-changes items))
|
||||
|
||||
(binding [*touched-changes* (volatile! #{})]
|
||||
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
|
||||
(let [result (reduce #(or (process-change %1 %2 libraries) %1) data items)]
|
||||
(reduce process-touched-change result @*touched-changes*)))))
|
||||
|
||||
;; --- Comment Threads
|
||||
|
||||
(defmethod process-change :set-comment-thread-position
|
||||
[data {:keys [page-id comment-thread-id position frame-id]}]
|
||||
[data {:keys [page-id comment-thread-id position frame-id]} _]
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
(if (and position frame-id)
|
||||
@@ -524,7 +535,7 @@
|
||||
;; --- Guides
|
||||
|
||||
(defmethod process-change :set-guide
|
||||
[data {:keys [page-id id params]}]
|
||||
[data {:keys [page-id id params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -540,7 +551,7 @@
|
||||
;; --- Flows
|
||||
|
||||
(defmethod process-change :set-flow
|
||||
[data {:keys [page-id id params]}]
|
||||
[data {:keys [page-id id params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -556,7 +567,7 @@
|
||||
;; --- Grids
|
||||
|
||||
(defmethod process-change :set-default-grid
|
||||
[data {:keys [page-id grid-type params]}]
|
||||
[data {:keys [page-id grid-type params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -593,7 +604,7 @@
|
||||
(update state :media-refs into xform media-refs)))
|
||||
|
||||
(defmethod process-change :add-obj
|
||||
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
|
||||
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]} _]
|
||||
;; NOTE: we only perform hard validation on backend
|
||||
#?(:clj (validate-shape obj page-id))
|
||||
|
||||
@@ -628,7 +639,7 @@
|
||||
objects))
|
||||
|
||||
(defmethod process-change :mod-obj
|
||||
[data {:keys [page-id component-id] :as change}]
|
||||
[data {:keys [page-id component-id] :as change} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id :objects] process-operations change)
|
||||
(d/update-in-when data [:components component-id :objects] process-operations change)))
|
||||
@@ -658,19 +669,19 @@
|
||||
objects))
|
||||
|
||||
(defmethod process-change :reorder-children
|
||||
[data {:keys [page-id component-id] :as change}]
|
||||
[data {:keys [page-id component-id] :as change} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id :objects] process-children-reordering change)
|
||||
(d/update-in-when data [:components component-id :objects] process-children-reordering change)))
|
||||
|
||||
(defmethod process-change :del-obj
|
||||
[data {:keys [page-id component-id id ignore-touched]}]
|
||||
[data {:keys [page-id component-id id ignore-touched]} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id] ctst/delete-shape id ignore-touched)
|
||||
(d/update-in-when data [:components component-id] ctst/delete-shape id ignore-touched)))
|
||||
|
||||
(defmethod process-change :fix-obj
|
||||
[data {:keys [page-id component-id id] :as params}]
|
||||
[data {:keys [page-id component-id id] :as params} _]
|
||||
(letfn [(fix-container [container]
|
||||
(case (:fix params :broken-children)
|
||||
:broken-children (ctst/fix-broken-children container id)
|
||||
@@ -682,7 +693,7 @@
|
||||
(d/update-in-when data [:components component-id] fix-container))))
|
||||
|
||||
(defmethod process-change :reg-objects
|
||||
[data {:keys [page-id component-id shapes]}]
|
||||
[data {:keys [page-id component-id shapes]} _]
|
||||
;; FIXME: Improve performance
|
||||
(letfn [(reg-objects [objects]
|
||||
(let [lookup (d/getf objects)
|
||||
@@ -734,7 +745,7 @@
|
||||
|
||||
(defmethod process-change :mov-objects
|
||||
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
|
||||
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
|
||||
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]} _]
|
||||
(letfn [(calculate-invalid-targets [objects shape-id]
|
||||
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
|
||||
(->> (get-in objects [shape-id :shapes])
|
||||
@@ -849,7 +860,7 @@
|
||||
(d/update-in-when data [:components component-id :objects] move-objects))))
|
||||
|
||||
(defmethod process-change :add-page
|
||||
[data {:keys [id name page]}]
|
||||
[data {:keys [id name page]} _]
|
||||
(when (and id name page)
|
||||
(ex/raise :type :conflict
|
||||
:hint "id+name or page should be provided, never both"))
|
||||
@@ -859,7 +870,7 @@
|
||||
(ctpl/add-page data page)))
|
||||
|
||||
(defmethod process-change :mod-page
|
||||
[data {:keys [id] :as params}]
|
||||
[data {:keys [id] :as params} _]
|
||||
(d/update-in-when data [:pages-index id]
|
||||
(fn [page]
|
||||
(let [name (get params :name)
|
||||
@@ -889,7 +900,7 @@
|
||||
(dissoc :pixel-grid-opacity))))))
|
||||
|
||||
(defmethod process-change :set-plugin-data
|
||||
[data {:keys [object-type object-id page-id namespace key value]}]
|
||||
[data {:keys [object-type object-id page-id namespace key value]} _]
|
||||
(letfn [(update-fn [data]
|
||||
(if (some? value)
|
||||
(assoc-in data [:plugin-data namespace key] value)
|
||||
@@ -915,83 +926,83 @@
|
||||
(d/update-in-when data [:components object-id] update-fn))))
|
||||
|
||||
(defmethod process-change :del-page
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctpl/delete-page data id))
|
||||
|
||||
(defmethod process-change :mov-page
|
||||
[data {:keys [id index]}]
|
||||
[data {:keys [id index]} _]
|
||||
(update data :pages d/insert-at-index index [id]))
|
||||
|
||||
(defmethod process-change :add-color
|
||||
[data {:keys [color]}]
|
||||
[data {:keys [color]} _]
|
||||
(ctl/add-color data color))
|
||||
|
||||
(defmethod process-change :mod-color
|
||||
[data {:keys [color]}]
|
||||
[data {:keys [color]} _]
|
||||
(ctl/set-color data color))
|
||||
|
||||
(defmethod process-change :del-color
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctl/delete-color data id))
|
||||
|
||||
;; -- Media
|
||||
|
||||
(defmethod process-change :add-media
|
||||
[data {:keys [object]}]
|
||||
[data {:keys [object]} _]
|
||||
(update data :media assoc (:id object) object))
|
||||
|
||||
(defmethod process-change :mod-media
|
||||
[data {:keys [object]}]
|
||||
[data {:keys [object]} _]
|
||||
(d/update-in-when data [:media (:id object)] merge object))
|
||||
|
||||
(defmethod process-change :del-media
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(d/update-when data :media dissoc id))
|
||||
|
||||
;; -- Components
|
||||
|
||||
(defmethod process-change :add-component
|
||||
[data params]
|
||||
[data params _]
|
||||
(ctkl/add-component data params))
|
||||
|
||||
(defmethod process-change :mod-component
|
||||
[data params]
|
||||
[data params _]
|
||||
(ctkl/mod-component data params))
|
||||
|
||||
(defmethod process-change :del-component
|
||||
[data {:keys [id skip-undelete? delta]}]
|
||||
[data {:keys [id skip-undelete? delta]} _]
|
||||
(ctf/delete-component data id skip-undelete? delta))
|
||||
|
||||
(defmethod process-change :restore-component
|
||||
[data {:keys [id page-id]}]
|
||||
[data {:keys [id page-id]} _]
|
||||
(ctf/restore-component data id page-id))
|
||||
|
||||
(defmethod process-change :purge-component
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctf/purge-component data id))
|
||||
|
||||
;; -- Typography
|
||||
|
||||
(defmethod process-change :add-typography
|
||||
[data {:keys [typography]}]
|
||||
[data {:keys [typography]} _]
|
||||
(ctyl/add-typography data typography))
|
||||
|
||||
(defmethod process-change :mod-typography
|
||||
[data {:keys [typography]}]
|
||||
[data {:keys [typography]} _]
|
||||
(ctyl/update-typography data (:id typography) merge typography))
|
||||
|
||||
(defmethod process-change :del-typography
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctyl/delete-typography data id))
|
||||
|
||||
;; -- Design Tokens
|
||||
|
||||
(defmethod process-change :set-tokens-lib
|
||||
[data {:keys [tokens-lib]}]
|
||||
[data {:keys [tokens-lib]} _]
|
||||
(assoc data :tokens-lib tokens-lib))
|
||||
|
||||
(defmethod process-change :set-token
|
||||
[data {:keys [set-id token-id attrs]}]
|
||||
[data {:keys [set-id token-id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1008,7 +1019,7 @@
|
||||
(ctob/make-token (merge prev-token attrs)))))))))
|
||||
|
||||
(defmethod process-change :set-token-set
|
||||
[data {:keys [id attrs]}]
|
||||
[data {:keys [id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1023,7 +1034,7 @@
|
||||
(ctob/update-set lib' id (fn [_] (ctob/make-token-set attrs))))))))
|
||||
|
||||
(defmethod process-change :set-token-theme
|
||||
[data {:keys [id attrs]}]
|
||||
[data {:keys [id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1041,35 +1052,67 @@
|
||||
(ctob/make-token-theme (merge prev-token-theme attrs)))))))))
|
||||
|
||||
(defmethod process-change :set-active-token-themes
|
||||
[data {:keys [theme-paths]}]
|
||||
[data {:keys [theme-paths]} _]
|
||||
(update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
|
||||
(ctob/set-active-themes theme-paths))))
|
||||
|
||||
(defmethod process-change :rename-token-set-group
|
||||
[data {:keys [set-group-path set-group-fname]}]
|
||||
[data {:keys [set-group-path set-group-fname]} _]
|
||||
(update data :tokens-lib (fn [lib]
|
||||
(-> lib
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/rename-set-group set-group-path set-group-fname)))))
|
||||
|
||||
(defmethod process-change :move-token-set
|
||||
[data {:keys [from-path to-path before-path before-group] :as changes}]
|
||||
[data {:keys [from-path to-path before-path before-group] :as changes} _]
|
||||
(update data :tokens-lib #(-> %
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/move-set from-path to-path before-path before-group))))
|
||||
|
||||
(defmethod process-change :move-token-set-group
|
||||
[data {:keys [from-path to-path before-path before-group]}]
|
||||
[data {:keys [from-path to-path before-path before-group]} _]
|
||||
(update data :tokens-lib #(-> %
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/move-set-group from-path to-path before-path before-group))))
|
||||
|
||||
;; === Design Tokens configuration
|
||||
;; --- Design Tokens configuration
|
||||
|
||||
(defmethod process-change :set-base-font-size
|
||||
[data {:keys [base-font-size]}]
|
||||
[data {:keys [base-font-size]} _]
|
||||
(ctf/set-base-font-size data base-font-size))
|
||||
|
||||
;; --- Validate Shapes
|
||||
|
||||
#?(:clj
|
||||
(defmethod process-change :validate-shapes
|
||||
[data _ _]
|
||||
data))
|
||||
|
||||
#?(:cljs
|
||||
(defmethod process-change :validate-shapes
|
||||
[data {:keys [page-id shape-ids context]} libraries]
|
||||
(if libraries
|
||||
(println "Validating shapes: \n"
|
||||
" page-id:" (str page-id) "\n"
|
||||
" shape-ids:" (str shape-ids) "\n"
|
||||
" context:" context)
|
||||
(let [file {:data data :id uuid/zero}
|
||||
errors (reduce (fn [acc shape-id]
|
||||
(if-let [page (ctpl/get-page data page-id)]
|
||||
(let [page-errors (val/validate-shape shape-id file page libraries)]
|
||||
(if (seq page-errors)
|
||||
(into acc page-errors)
|
||||
acc))
|
||||
acc))
|
||||
[]
|
||||
shape-ids)]
|
||||
(when (seq errors)
|
||||
(ex/raise :type :validation
|
||||
:code :referential-integrity
|
||||
:hint (str "error on validating shapes: " context)
|
||||
:details errors))
|
||||
data))
|
||||
data))
|
||||
|
||||
;; === Operations
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
translation?
|
||||
(assoc :translation? true)))
|
||||
|
||||
(defn set-skip-component-sync?
|
||||
[changes skip-component-sync?]
|
||||
(cond-> changes
|
||||
skip-component-sync?
|
||||
(assoc :skip-component-sync? true)))
|
||||
|
||||
(defn with-page
|
||||
[changes page]
|
||||
(vary-meta changes assoc
|
||||
@@ -1197,7 +1203,6 @@
|
||||
[changes]
|
||||
(::page-id (meta changes)))
|
||||
|
||||
|
||||
(defn set-text-content
|
||||
[changes id content prev-content]
|
||||
(assert-page-id! changes)
|
||||
@@ -1218,3 +1223,12 @@
|
||||
(-> changes
|
||||
(update :redo-changes conj redo-change)
|
||||
(update :undo-changes conj undo-change))))
|
||||
|
||||
;; Validate Shapes
|
||||
|
||||
(defn validate-shapes
|
||||
[changes page-id shape-ids context]
|
||||
(update changes :redo-changes conj {:type :validate-shapes
|
||||
:page-id page-id
|
||||
:shape-ids (vec shape-ids)
|
||||
:context context}))
|
||||
@@ -166,7 +166,6 @@
|
||||
(not (ctob/token-name-path-exists? token-name tokens-tree)))
|
||||
new-tokens))))]])
|
||||
(defn find-refs [value]
|
||||
(prn value)
|
||||
(cond
|
||||
(string? value)
|
||||
(cto/find-token-value-references value)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.component :as ctk]
|
||||
@@ -569,7 +568,17 @@
|
||||
objects (:objects page)
|
||||
file-data (:data file)
|
||||
first-child (get objects (first shapes))
|
||||
prop-names (cfv/extract-properties-names first-child file-data)]
|
||||
|
||||
extract-properties-names
|
||||
(fn [shape]
|
||||
;; Get the names of the properties of the shape's component
|
||||
(->> shape
|
||||
(#(ctkl/get-component file-data (:component-id %) true))
|
||||
:variant-properties
|
||||
(map :name)))
|
||||
|
||||
prop-names (extract-properties-names first-child)]
|
||||
|
||||
(run! (fn [child-id]
|
||||
(when-let [child (get objects child-id)]
|
||||
(if (not (ctk/is-variant? child))
|
||||
@@ -583,7 +592,7 @@
|
||||
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
|
||||
child file page
|
||||
:variant-id shape-id))
|
||||
(when (not= prop-names (cfv/extract-properties-names child file-data))
|
||||
(when (not= prop-names (extract-properties-names child))
|
||||
(report-error :invalid-variant-properties
|
||||
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
|
||||
child file page
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
(ns app.common.files.variant
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.component :as ctc]
|
||||
[app.common.types.components-list :as ctcl]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.variant :as ctv]))
|
||||
|
||||
(defn find-variant-components
|
||||
"Find a list of the components that belongs to this variant-id"
|
||||
"Find the components that belong to the variant container identified by `variant-id`,
|
||||
preserving the order defined by the container's shapes.
|
||||
|
||||
Example return:
|
||||
(<component1> <component2> ...)"
|
||||
([data variant-id]
|
||||
(let [page-id (->> data
|
||||
:components
|
||||
@@ -22,22 +25,24 @@
|
||||
objects (dm/get-in data [:pages-index page-id :objects])]
|
||||
(find-variant-components data objects variant-id)))
|
||||
([data objects variant-id]
|
||||
(assert (or (uuid? variant-id) (nil? variant-id)))
|
||||
;; We can't simply filter components, because we need to maintain the order
|
||||
(->> (dm/get-in objects [variant-id :shapes])
|
||||
(map #(dm/get-in objects [% :component-id]))
|
||||
(map #(ctcl/get-component data % true))
|
||||
reverse)))
|
||||
|
||||
(defn extract-properties-names
|
||||
[shape data]
|
||||
(->> shape
|
||||
(#(ctcl/get-component data (:component-id %) true))
|
||||
:variant-properties
|
||||
(map :name)))
|
||||
(let [container (get objects variant-id)]
|
||||
(if (ctv/variant-container? container)
|
||||
(->> (:shapes container)
|
||||
(map #(dm/get-in objects [% :component-id]))
|
||||
(map #(ctkl/get-component data % true))
|
||||
reverse)
|
||||
[]))))
|
||||
|
||||
(defn extract-properties-values
|
||||
"Get a map of properties associated to their possible values"
|
||||
"Get a map of variant property names to their distinct possible values,
|
||||
collected from all components that belong to the variant container.
|
||||
|
||||
Example return:
|
||||
[{:name 'Property 1' :value ('Value1' 'Value2')}]"
|
||||
[data objects variant-id]
|
||||
(assert (or (uuid? variant-id) (nil? variant-id)))
|
||||
(->> (find-variant-components data objects variant-id)
|
||||
(mapcat :variant-properties)
|
||||
(group-by :name)
|
||||
@@ -47,9 +52,13 @@
|
||||
:value (->> v (map :value) distinct)}
|
||||
mdata))))))
|
||||
|
||||
(defn get-variant-mains
|
||||
[component data]
|
||||
(assert (ctv/valid-variant-component? component) "expected valid component variant")
|
||||
(defn- get-variant-mains
|
||||
"Return the ids of the main instance shapes of the variant this component belongs to,
|
||||
in the order they appear in the container.
|
||||
|
||||
Example return:
|
||||
[<main-shape-a-id> <main-shape-b-id>]"
|
||||
[data component]
|
||||
(when-let [variant-id (:variant-id component)]
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
@@ -57,27 +66,20 @@
|
||||
(dm/get-in objects [variant-id :shapes]))))
|
||||
|
||||
(defn is-secondary-variant?
|
||||
[component data]
|
||||
(let [shapes (get-variant-mains component data)]
|
||||
"Return true if the component is a secondary variant in its variant container.
|
||||
The primary variant is the last one in the container's children list.
|
||||
Return false if the component is the primary variant or if it's not part of a variant."
|
||||
[data component]
|
||||
(let [shapes (get-variant-mains data component)]
|
||||
(and (seq shapes)
|
||||
(not= (:main-instance-id component) (last shapes)))))
|
||||
|
||||
(defn get-primary-variant
|
||||
"Return the main instance of the primary variant (the last one) in the variant container."
|
||||
[data component]
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
(get :objects))
|
||||
variant-id (:variant-id component)]
|
||||
(->> (dm/get-in objects [variant-id :shapes])
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
(get :objects))]
|
||||
(->> (get-variant-mains data component)
|
||||
peek
|
||||
(get objects))))
|
||||
|
||||
(defn get-primary-component
|
||||
[data component-id]
|
||||
(when-let [component (ctcl/get-component data component-id)]
|
||||
(if (ctc/is-variant? component)
|
||||
(->> component
|
||||
(get-primary-variant data)
|
||||
:component-id
|
||||
(ctcl/get-component data))
|
||||
component)))
|
||||
@@ -290,8 +290,11 @@
|
||||
duplicated-parent?
|
||||
(->> ids-map vals (some #(= % (:parent-id first-shape))))
|
||||
|
||||
grid-parent?
|
||||
(and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
|
||||
|
||||
changes
|
||||
(if (and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
|
||||
(if grid-parent?
|
||||
(let [target-cell (-> position meta :cell)
|
||||
|
||||
[row column]
|
||||
@@ -313,7 +316,19 @@
|
||||
changes
|
||||
(reduce #(pcb/add-object %1 %2 {:ignore-touched true})
|
||||
changes
|
||||
(rest new-shapes))]
|
||||
(rest new-shapes))
|
||||
|
||||
ids-to-validate (cond-> [(:id first-shape)]
|
||||
grid-parent?
|
||||
(conj (:parent-id first-shape)))
|
||||
|
||||
changes (if (seq ids-to-validate)
|
||||
(pcb/validate-shapes changes
|
||||
(:id page)
|
||||
ids-to-validate
|
||||
(str "generate-instantiate-component: " component-id
|
||||
" under parent-id" (or parent-id " root")))
|
||||
changes)]
|
||||
|
||||
[new-shape changes])))
|
||||
|
||||
@@ -3123,7 +3138,6 @@
|
||||
;; we calculate a new one because the components will have created new shapes.
|
||||
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
|
||||
|
||||
|
||||
;; If there is an alt-duplication we change to root
|
||||
;; For variants so the copy is made as a child of root
|
||||
;; This is because inside a variant-container can't be a copy
|
||||
@@ -3135,7 +3149,6 @@
|
||||
(assoc :parent-id uuid/zero :frame-id uuid/zero)))
|
||||
shapes)
|
||||
|
||||
|
||||
changes (-> changes
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects all-objects)
|
||||
@@ -3165,7 +3178,20 @@
|
||||
(comp
|
||||
(filter #(= :add-obj (:type %)))
|
||||
(map #(vector (:old-id %) (-> % :obj :id))))
|
||||
(:redo-changes changes))]
|
||||
(:redo-changes changes))
|
||||
|
||||
copied-components
|
||||
(ctn/get-all-instance-roots (:objects page) ids)
|
||||
|
||||
ids-to-validate
|
||||
(map #(get ids-map % %) copied-components)
|
||||
|
||||
changes (if (seq ids-to-validate)
|
||||
(pcb/validate-shapes changes
|
||||
(:id page)
|
||||
ids-to-validate
|
||||
(cond-> (str "generate-duplicate-changes: " ids)))
|
||||
changes)]
|
||||
|
||||
(-> changes
|
||||
(generate-duplicate-flows shapes page ids-map)
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
(reduce check-shape changes mod-obj-changes)))
|
||||
|
||||
(defn generate-update-shapes
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? extra-context]}]
|
||||
(let [changes (reduce
|
||||
(fn [changes id]
|
||||
(let [opts {:attrs attrs
|
||||
@@ -96,7 +96,19 @@
|
||||
(pcb/reorder-grid-children ids))
|
||||
|
||||
(not ignore-touched)
|
||||
(generate-unapply-tokens objects changed-sub-attr))]
|
||||
(generate-unapply-tokens objects changed-sub-attr))
|
||||
|
||||
page-id (pcb/get-page-id changes)
|
||||
modified-components (ctn/get-all-instance-roots objects ids)
|
||||
|
||||
changes (if (and page-id (seq modified-components))
|
||||
(pcb/validate-shapes changes
|
||||
page-id
|
||||
modified-components
|
||||
(cond-> (str "generate-update-shapes: " ids " " attrs)
|
||||
(some? extra-context)
|
||||
(str " \n -> from " extra-context)))
|
||||
changes)]
|
||||
changes))
|
||||
|
||||
(defn- generate-update-shape-flags
|
||||
@@ -248,8 +260,8 @@
|
||||
page-id (pcb/get-page-id changes)
|
||||
page (or (pcb/get-page changes)
|
||||
(ctpl/get-page data page-id))
|
||||
|
||||
ids (cfh/clean-loops objects ids)
|
||||
|
||||
in-component-copy?
|
||||
(fn [shape-id]
|
||||
;; Look for shapes that are inside a component copy, but are
|
||||
@@ -258,7 +270,7 @@
|
||||
;; If we want to specifically allow altering the copies, this is
|
||||
;; a special case, like a component swap, in which case we want
|
||||
;; to delete the old shape
|
||||
(let [shape (get objects shape-id)]
|
||||
(let [shape (get objects shape-id)]
|
||||
(and (ctn/has-any-copy-parent? objects shape)
|
||||
(not allow-altering-copies))))
|
||||
|
||||
@@ -437,7 +449,19 @@
|
||||
(into []
|
||||
(remove #(and (ctsi/has-destination %)
|
||||
(id-to-delete? (:destination %))))
|
||||
interactions))))))]
|
||||
interactions))))))
|
||||
|
||||
modified-components (ctn/get-all-instance-roots objects (disj all-parents uuid/zero))
|
||||
;; There is no need to validate deleted objects. Probably also no need to validate hidden or unmasked objects,
|
||||
;; but we may think of it
|
||||
|
||||
changes (if (seq modified-components)
|
||||
(pcb/validate-shapes changes
|
||||
page-id
|
||||
modified-components
|
||||
(str "generate-delete-shapes: " ids))
|
||||
changes)]
|
||||
|
||||
[all-parents changes])))
|
||||
|
||||
|
||||
|
||||
@@ -172,10 +172,10 @@
|
||||
new-props (- min-props
|
||||
(+ (count props)
|
||||
(if add-name? 1 0)))
|
||||
props (ctv/add-new-props props (repeat new-props ""))]
|
||||
props (ctv/add-new-properties props (repeat new-props ""))]
|
||||
|
||||
(if add-name?
|
||||
(ctv/add-new-prop props (:name component))
|
||||
(ctv/add-new-property props (:name component))
|
||||
props)))
|
||||
|
||||
(defn- create-new-properties-from-non-variant
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
[app.common.math :as mth]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
@@ -27,22 +26,6 @@
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const UUID-U8-SIZE 16)
|
||||
|
||||
(defn set-shape-children
|
||||
"Uploads the child id list via the dynamic `_set_children` path (handles any
|
||||
count). The browser also has fixed-arity fast paths for the incremental edit
|
||||
path; this dynamic one is the shared/batch version."
|
||||
[children]
|
||||
(let [children (into [] (filter uuid?) children)]
|
||||
(if (empty? children)
|
||||
(h/call wasm/internal-module "_set_children_0")
|
||||
(let [heap (mem/get-heap-u32)
|
||||
size (mem/get-alloc-size children UUID-U8-SIZE)
|
||||
offset (mem/alloc->offset-32 size)]
|
||||
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
|
||||
(h/call wasm/internal-module "_set_children")))))
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.common.render-wasm.api.shapes
|
||||
"Batched shape property serialization for improved WASM performance.
|
||||
|
||||
This module provides a single WASM call to set all base shape properties,
|
||||
replacing multiple individual calls (use_shape, set_parent, set_shape_type,
|
||||
etc.) with one batched operation."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
;; Binary layout constants matching Rust implementation:
|
||||
;;
|
||||
;; | Offset | Size | Field | Type |
|
||||
;; |--------|------|--------------|-----------------------------------|
|
||||
;; | 0 | 16 | id | UUID (4 × u32 LE) |
|
||||
;; | 16 | 16 | parent_id | UUID (4 × u32 LE) |
|
||||
;; | 32 | 1 | shape_type | u8 |
|
||||
;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) |
|
||||
;; | 34 | 1 | blend_mode | u8 |
|
||||
;; | 35 | 1 | constraint_h | u8 (0xFF = None) |
|
||||
;; | 36 | 1 | constraint_v | u8 (0xFF = None) |
|
||||
;; | 37 | 3 | padding | - |
|
||||
;; | 40 | 4 | opacity | f32 LE |
|
||||
;; | 44 | 4 | rotation | f32 LE |
|
||||
;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) |
|
||||
;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) |
|
||||
;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) |
|
||||
;; |--------|------|--------------|-----------------------------------|
|
||||
;; | Total | 104 | | |
|
||||
|
||||
(def ^:const BASE-PROPS-SIZE 104)
|
||||
(def ^:const FLAG-CLIP-CONTENT 0x01)
|
||||
(def ^:const FLAG-HIDDEN 0x02)
|
||||
(def ^:const CONSTRAINT-NONE 0xFF)
|
||||
|
||||
(defn- write-uuid-to-heap
|
||||
"Write a UUID to the heap at the given byte offset using DataView."
|
||||
[dview offset id]
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
(.setUint32 dview offset (aget buffer 0) true)
|
||||
(.setUint32 dview (+ offset 4) (aget buffer 1) true)
|
||||
(.setUint32 dview (+ offset 8) (aget buffer 2) true)
|
||||
(.setUint32 dview (+ offset 12) (aget buffer 3) true)))
|
||||
|
||||
(defn- serialize-transform
|
||||
"Extract transform matrix values, defaulting to identity matrix."
|
||||
[transform]
|
||||
(if (some? transform)
|
||||
[(dm/get-prop transform :a)
|
||||
(dm/get-prop transform :b)
|
||||
(dm/get-prop transform :c)
|
||||
(dm/get-prop transform :d)
|
||||
(dm/get-prop transform :e)
|
||||
(dm/get-prop transform :f)]
|
||||
[1.0 0.0 0.0 1.0 0.0 0.0])) ; identity matrix
|
||||
|
||||
(defn- serialize-selrect
|
||||
"Extract selrect values."
|
||||
[selrect]
|
||||
(if (some? selrect)
|
||||
[(dm/get-prop selrect :x1)
|
||||
(dm/get-prop selrect :y1)
|
||||
(dm/get-prop selrect :x2)
|
||||
(dm/get-prop selrect :y2)]
|
||||
[0.0 0.0 0.0 0.0]))
|
||||
|
||||
(defn set-shape-base-props
|
||||
"Set all base shape properties in a single WASM call.
|
||||
|
||||
This replaces the following individual calls:
|
||||
- use-shape
|
||||
- set-parent-id
|
||||
- set-shape-type
|
||||
- set-shape-clip-content
|
||||
- set-shape-rotation
|
||||
- set-shape-transform
|
||||
- set-shape-blend-mode
|
||||
- set-shape-opacity
|
||||
- set-shape-hidden
|
||||
- set-shape-selrect
|
||||
- set-shape-corners
|
||||
- set-shape-constraints (clear + h + v)
|
||||
|
||||
Returns nil."
|
||||
[shape]
|
||||
(when (wasm/live?)
|
||||
(let [id (dm/get-prop shape :id)
|
||||
parent-id (get shape :parent-id)
|
||||
shape-type (dm/get-prop shape :type)
|
||||
|
||||
clip-content (if (= shape-type :frame)
|
||||
(not (get shape :show-content))
|
||||
false)
|
||||
hidden (get shape :hidden false)
|
||||
|
||||
flags (cond-> 0
|
||||
clip-content (bit-or FLAG-CLIP-CONTENT)
|
||||
hidden (bit-or FLAG-HIDDEN))
|
||||
|
||||
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
|
||||
constraint-h (let [c (get shape :constraints-h)]
|
||||
(sr/translate-constraint-h c))
|
||||
constraint-v (let [c (get shape :constraints-v)]
|
||||
(sr/translate-constraint-v c))
|
||||
|
||||
opacity (d/nilv (get shape :opacity) 1.0)
|
||||
rotation (d/nilv (get shape :rotation) 0.0)
|
||||
|
||||
;; Transform matrix
|
||||
[ta tb tc td te tf] (serialize-transform (get shape :transform))
|
||||
|
||||
;; Selrect
|
||||
selrect (get shape :selrect)
|
||||
[sx1 sy1 sx2 sy2] (serialize-selrect selrect)
|
||||
|
||||
;; Corners
|
||||
r1 (d/nilv (get shape :r1) 0.0)
|
||||
r2 (d/nilv (get shape :r2) 0.0)
|
||||
r3 (d/nilv (get shape :r3) 0.0)
|
||||
r4 (d/nilv (get shape :r4) 0.0)
|
||||
|
||||
;; Allocate buffer and get DataView
|
||||
offset (mem/alloc BASE-PROPS-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
|
||||
;; Write id (offset 0, 16 bytes)
|
||||
(write-uuid-to-heap dview offset id)
|
||||
|
||||
;; Write parent_id (offset 16, 16 bytes)
|
||||
(write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero))
|
||||
|
||||
;; Write shape_type (offset 32, 1 byte)
|
||||
(.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type))
|
||||
|
||||
;; Write flags (offset 33, 1 byte)
|
||||
(.setUint8 dview (+ offset 33) flags)
|
||||
|
||||
;; Write blend_mode (offset 34, 1 byte)
|
||||
(.setUint8 dview (+ offset 34) blend-mode)
|
||||
|
||||
;; Write constraint_h (offset 35, 1 byte)
|
||||
(.setUint8 dview (+ offset 35) constraint-h)
|
||||
|
||||
;; Write constraint_v (offset 36, 1 byte)
|
||||
(.setUint8 dview (+ offset 36) constraint-v)
|
||||
|
||||
;; Padding at offset 37-39 (already zero from alloc)
|
||||
|
||||
;; Write opacity (offset 40, f32)
|
||||
(.setFloat32 dview (+ offset 40) opacity true)
|
||||
|
||||
;; Write rotation (offset 44, f32)
|
||||
(.setFloat32 dview (+ offset 44) rotation true)
|
||||
|
||||
;; Write transform matrix (offset 48, 6 × f32)
|
||||
(.setFloat32 dview (+ offset 48) ta true)
|
||||
(.setFloat32 dview (+ offset 52) tb true)
|
||||
(.setFloat32 dview (+ offset 56) tc true)
|
||||
(.setFloat32 dview (+ offset 60) td true)
|
||||
(.setFloat32 dview (+ offset 64) te true)
|
||||
(.setFloat32 dview (+ offset 68) tf true)
|
||||
|
||||
;; Write selrect (offset 72, 4 × f32)
|
||||
(.setFloat32 dview (+ offset 72) sx1 true)
|
||||
(.setFloat32 dview (+ offset 76) sy1 true)
|
||||
(.setFloat32 dview (+ offset 80) sx2 true)
|
||||
(.setFloat32 dview (+ offset 84) sy2 true)
|
||||
|
||||
;; Write corners (offset 88, 4 × f32)
|
||||
(.setFloat32 dview (+ offset 88) r1 true)
|
||||
(.setFloat32 dview (+ offset 92) r2 true)
|
||||
(.setFloat32 dview (+ offset 96) r3 true)
|
||||
(.setFloat32 dview (+ offset 100) r4 true)
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_base_props")
|
||||
|
||||
nil)))
|
||||
@@ -0,0 +1,453 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.common.render-wasm.api.upload
|
||||
"Enlarged per-shape + multi-shape structural upload for WASM cold load.
|
||||
|
||||
Writes a binary batch consumed by `_set_shapes_batch`. Remaining
|
||||
host-specific attrs (image bytes, text, path, grid tracks) are applied
|
||||
afterwards via the existing per-shape setters."
|
||||
(:require
|
||||
[app.common.buffer :as buf]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(def ^:const BASE-PROPS-SIZE 104)
|
||||
(def ^:const FLAG-CLIP-CONTENT 0x01)
|
||||
(def ^:const FLAG-HIDDEN 0x02)
|
||||
|
||||
(def ^:const SECTION-CHILDREN 0x01)
|
||||
(def ^:const SECTION-BLUR-LAYER 0x02)
|
||||
(def ^:const SECTION-BLUR-BG 0x04)
|
||||
(def ^:const SECTION-SHADOWS 0x08)
|
||||
(def ^:const SECTION-MASKED 0x10)
|
||||
(def ^:const SECTION-BOOL-TYPE 0x20)
|
||||
(def ^:const SECTION-GROW-TYPE 0x40)
|
||||
(def ^:const SECTION-LAYOUT-ITEM 0x80)
|
||||
(def ^:const SECTION-FLEX 0x100)
|
||||
(def ^:const SECTION-FILLS 0x200)
|
||||
(def ^:const SECTION-STROKES 0x400)
|
||||
|
||||
;; Stroke header before RawFillData (must match upload_batch.rs).
|
||||
(def ^:const STROKE-HEADER-U8-SIZE 36)
|
||||
(def ^:const STROKE-ALIGN-CENTER 0)
|
||||
(def ^:const STROKE-ALIGN-INNER 1)
|
||||
(def ^:const STROKE-ALIGN-OUTER 2)
|
||||
|
||||
(defn- write-uuid!
|
||||
[dview offset id]
|
||||
(buf/write-uuid dview offset id)
|
||||
(+ offset 16))
|
||||
|
||||
(defn- write-base-props!
|
||||
"Write the 104-byte RawBasePropsData at `offset`. Returns next offset."
|
||||
[dview offset shape]
|
||||
(let [id (dm/get-prop shape :id)
|
||||
parent-id (get shape :parent-id)
|
||||
shape-type (dm/get-prop shape :type)
|
||||
clip-content (if (= shape-type :frame)
|
||||
(not (get shape :show-content))
|
||||
false)
|
||||
hidden (get shape :hidden false)
|
||||
flags (cond-> 0
|
||||
clip-content (bit-or FLAG-CLIP-CONTENT)
|
||||
hidden (bit-or FLAG-HIDDEN))
|
||||
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
|
||||
constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none))
|
||||
constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none))
|
||||
opacity (d/nilv (get shape :opacity) 1.0)
|
||||
rotation (d/nilv (get shape :rotation) 0.0)
|
||||
transform (get shape :transform)
|
||||
[ta tb tc td te tf]
|
||||
(if (some? transform)
|
||||
[(dm/get-prop transform :a)
|
||||
(dm/get-prop transform :b)
|
||||
(dm/get-prop transform :c)
|
||||
(dm/get-prop transform :d)
|
||||
(dm/get-prop transform :e)
|
||||
(dm/get-prop transform :f)]
|
||||
[1.0 0.0 0.0 1.0 0.0 0.0])
|
||||
selrect (get shape :selrect)
|
||||
[sx1 sy1 sx2 sy2]
|
||||
(if (some? selrect)
|
||||
[(dm/get-prop selrect :x1)
|
||||
(dm/get-prop selrect :y1)
|
||||
(dm/get-prop selrect :x2)
|
||||
(dm/get-prop selrect :y2)]
|
||||
[0.0 0.0 0.0 0.0])
|
||||
r1 (d/nilv (get shape :r1) 0.0)
|
||||
r2 (d/nilv (get shape :r2) 0.0)
|
||||
r3 (d/nilv (get shape :r3) 0.0)
|
||||
r4 (d/nilv (get shape :r4) 0.0)]
|
||||
|
||||
(write-uuid! dview offset id)
|
||||
(write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero))
|
||||
(buf/write-u8 dview (+ offset 32) (sr/translate-shape-type shape-type))
|
||||
(buf/write-u8 dview (+ offset 33) flags)
|
||||
(buf/write-u8 dview (+ offset 34) blend-mode)
|
||||
(buf/write-u8 dview (+ offset 35) constraint-h)
|
||||
(buf/write-u8 dview (+ offset 36) constraint-v)
|
||||
(buf/write-f32 dview (+ offset 40) opacity)
|
||||
(buf/write-f32 dview (+ offset 44) rotation)
|
||||
(buf/write-f32 dview (+ offset 48) ta)
|
||||
(buf/write-f32 dview (+ offset 52) tb)
|
||||
(buf/write-f32 dview (+ offset 56) tc)
|
||||
(buf/write-f32 dview (+ offset 60) td)
|
||||
(buf/write-f32 dview (+ offset 64) te)
|
||||
(buf/write-f32 dview (+ offset 68) tf)
|
||||
(buf/write-f32 dview (+ offset 72) sx1)
|
||||
(buf/write-f32 dview (+ offset 76) sy1)
|
||||
(buf/write-f32 dview (+ offset 80) sx2)
|
||||
(buf/write-f32 dview (+ offset 84) sy2)
|
||||
(buf/write-f32 dview (+ offset 88) r1)
|
||||
(buf/write-f32 dview (+ offset 92) r2)
|
||||
(buf/write-f32 dview (+ offset 96) r3)
|
||||
(buf/write-f32 dview (+ offset 100) r4)
|
||||
(+ offset BASE-PROPS-SIZE)))
|
||||
|
||||
(defn- write-blur!
|
||||
[dview offset blur]
|
||||
(buf/write-u8 dview offset (if (get blur :hidden) 1 0))
|
||||
(buf/write-f32 dview (+ offset 4) (get blur :value 0))
|
||||
(+ offset 8))
|
||||
|
||||
(defn- write-shadow!
|
||||
[dview offset shadow]
|
||||
(let [color (get shadow :color)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))]
|
||||
(buf/write-u32 dview offset rgba)
|
||||
(buf/write-f32 dview (+ offset 4) (get shadow :blur 0))
|
||||
(buf/write-f32 dview (+ offset 8) (get shadow :spread 0))
|
||||
(buf/write-f32 dview (+ offset 12) (get shadow :offset-x 0))
|
||||
(buf/write-f32 dview (+ offset 16) (get shadow :offset-y 0))
|
||||
(buf/write-u8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style)))
|
||||
(buf/write-u8 dview (+ offset 21) (if (get shadow :hidden) 1 0))
|
||||
(+ offset 24)))
|
||||
|
||||
(defn- write-flex!
|
||||
[dview offset shape]
|
||||
(let [dir (-> (get shape :layout-flex-dir :row)
|
||||
(sr/translate-layout-flex-dir))
|
||||
gap (get shape :layout-gap)
|
||||
row-gap (get gap :row-gap 0)
|
||||
column-gap (get gap :column-gap 0)
|
||||
align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items)
|
||||
align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content)
|
||||
justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items)
|
||||
justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content)
|
||||
wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type)
|
||||
padding (get shape :layout-padding)
|
||||
padding-top (get padding :p1 0)
|
||||
padding-right (get padding :p2 0)
|
||||
padding-bottom (get padding :p3 0)
|
||||
padding-left (get padding :p4 0)]
|
||||
(buf/write-u8 dview offset dir)
|
||||
(buf/write-u8 dview (+ offset 1) align-items)
|
||||
(buf/write-u8 dview (+ offset 2) align-content)
|
||||
(buf/write-u8 dview (+ offset 3) justify-items)
|
||||
(buf/write-u8 dview (+ offset 4) justify-content)
|
||||
(buf/write-u8 dview (+ offset 5) wrap-type)
|
||||
(buf/write-f32 dview (+ offset 8) row-gap)
|
||||
(buf/write-f32 dview (+ offset 12) column-gap)
|
||||
(buf/write-f32 dview (+ offset 16) padding-top)
|
||||
(buf/write-f32 dview (+ offset 20) padding-right)
|
||||
(buf/write-f32 dview (+ offset 24) padding-bottom)
|
||||
(buf/write-f32 dview (+ offset 28) padding-left)
|
||||
(+ offset 32)))
|
||||
|
||||
(defn- write-layout-item!
|
||||
[dview offset shape]
|
||||
(let [margins (get shape :layout-item-margin)
|
||||
margin-top (get margins :m1 0)
|
||||
margin-right (get margins :m2 0)
|
||||
margin-bottom (get margins :m3 0)
|
||||
margin-left (get margins :m4 0)
|
||||
h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing)
|
||||
v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing)
|
||||
align-self (-> (get shape :layout-item-align-self) sr/translate-align-self)
|
||||
max-h (get shape :layout-item-max-h)
|
||||
min-h (get shape :layout-item-min-h)
|
||||
max-w (get shape :layout-item-max-w)
|
||||
min-w (get shape :layout-item-min-w)
|
||||
is-absolute (boolean (get shape :layout-item-absolute))
|
||||
z-index (get shape :layout-item-z-index)
|
||||
flags (cond-> 0
|
||||
(some? max-h) (bit-or 0x01)
|
||||
(some? min-h) (bit-or 0x02)
|
||||
(some? max-w) (bit-or 0x04)
|
||||
(some? min-w) (bit-or 0x08)
|
||||
is-absolute (bit-or 0x10))]
|
||||
(buf/write-f32 dview offset margin-top)
|
||||
(buf/write-f32 dview (+ offset 4) margin-right)
|
||||
(buf/write-f32 dview (+ offset 8) margin-bottom)
|
||||
(buf/write-f32 dview (+ offset 12) margin-left)
|
||||
(buf/write-u8 dview (+ offset 16) (d/nilv h-sizing 0))
|
||||
(buf/write-u8 dview (+ offset 17) (d/nilv v-sizing 0))
|
||||
(buf/write-u8 dview (+ offset 18) flags)
|
||||
(buf/write-u8 dview (+ offset 19) (d/nilv align-self 0))
|
||||
(buf/write-f32 dview (+ offset 20) (d/nilv max-h 0))
|
||||
(buf/write-f32 dview (+ offset 24) (d/nilv min-h 0))
|
||||
(buf/write-f32 dview (+ offset 28) (d/nilv max-w 0))
|
||||
(buf/write-f32 dview (+ offset 32) (d/nilv min-w 0))
|
||||
(buf/write-i32 dview (+ offset 36) (d/nilv z-index 0))
|
||||
(+ offset 40)))
|
||||
|
||||
(defn- write-fills-section!
|
||||
"Write fills in the same layout as `_set_shape_fills`:
|
||||
[u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset."
|
||||
[dview offset fills]
|
||||
(let [fills (types.fills/coerce (or fills []))
|
||||
byte-size (types.fills/get-byte-size fills)
|
||||
;; write-to expects a Uint32Array heap + u32 element offset
|
||||
heap-u32 (js/Uint32Array. (.-buffer dview))
|
||||
u32-off (quot offset 4)]
|
||||
(types.fills/write-to fills heap-u32 u32-off)
|
||||
(+ offset byte-size)))
|
||||
|
||||
(defn- write-stroke-fill!
|
||||
[dview offset stroke]
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)]
|
||||
(cond
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image)
|
||||
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
:else
|
||||
(types.fills.impl/write-solid-fill offset dview 0.0 "#000000"))))
|
||||
|
||||
(defn- write-stroke!
|
||||
[dview offset stroke]
|
||||
(let [width (or (:stroke-width stroke) 1.0)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
align (case (:stroke-alignment stroke)
|
||||
:inner STROKE-ALIGN-INNER
|
||||
:outer STROKE-ALIGN-OUTER
|
||||
STROKE-ALIGN-CENTER)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
per-side? (boolean (:stroke-per-side stroke))
|
||||
top (or (:stroke-width-top stroke) width)
|
||||
right (or (:stroke-width-right stroke) width)
|
||||
bottom (or (:stroke-width-bottom stroke) width)
|
||||
left (or (:stroke-width-left stroke) width)
|
||||
has-sides? (and per-side? (not= top right bottom left))]
|
||||
(buf/write-f32 dview offset width)
|
||||
(buf/write-u8 dview (+ offset 4) style)
|
||||
(buf/write-u8 dview (+ offset 5) align)
|
||||
(buf/write-u8 dview (+ offset 6) (d/nilv cap-start 0))
|
||||
(buf/write-u8 dview (+ offset 7) (d/nilv cap-end 0))
|
||||
(buf/write-f32 dview (+ offset 8) dash)
|
||||
(buf/write-f32 dview (+ offset 12) gap)
|
||||
(buf/write-u8 dview (+ offset 16) (if has-sides? 1 0))
|
||||
(buf/write-f32 dview (+ offset 20) top)
|
||||
(buf/write-f32 dview (+ offset 24) right)
|
||||
(buf/write-f32 dview (+ offset 28) bottom)
|
||||
(buf/write-f32 dview (+ offset 32) left)
|
||||
(write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke)
|
||||
(+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
|
||||
|
||||
(defn- visible-strokes
|
||||
[shape]
|
||||
(let [type (dm/get-prop shape :type)]
|
||||
(if (= type :group)
|
||||
[]
|
||||
(into [] (remove :hidden) (or (get shape :strokes) [])))))
|
||||
|
||||
(defn- write-strokes-section!
|
||||
[dview offset strokes]
|
||||
(buf/write-u32 dview offset (count strokes))
|
||||
(reduce (fn [o s] (write-stroke! dview o s))
|
||||
(+ offset 4)
|
||||
strokes))
|
||||
|
||||
(defn write-shape-payload!
|
||||
"Serialize one shape's structural payload into `dview` starting at `offset`
|
||||
(payload only — no length prefix). Returns the offset after the payload.
|
||||
|
||||
Options:
|
||||
- `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load).
|
||||
- `:include-fills-strokes?` — when true, emit FILLS + STROKES sections."
|
||||
[dview offset shape {:keys [include-layout? include-fills-strokes?]
|
||||
:or {include-layout? false
|
||||
include-fills-strokes? false}}]
|
||||
(let [shape-type (dm/get-prop shape :type)
|
||||
children (into [] (filter uuid?) (get shape :shapes))
|
||||
blur (get shape :blur)
|
||||
bg-blur (get shape :background-blur)
|
||||
shadows (or (get shape :shadow) [])
|
||||
masked? (and (= shape-type :group) (boolean (get shape :masked-group)))
|
||||
bool-type (when (= shape-type :bool) (get shape :bool-type))
|
||||
grow-type (when (= shape-type :text) (get shape :grow-type))
|
||||
flex? (and include-layout? (ctl/flex-layout? shape))
|
||||
layout-item? include-layout?
|
||||
strokes (when include-fills-strokes? (visible-strokes shape))
|
||||
|
||||
mask (cond-> 0
|
||||
true (bit-or SECTION-CHILDREN)
|
||||
(some? blur) (bit-or SECTION-BLUR-LAYER)
|
||||
(some? bg-blur) (bit-or SECTION-BLUR-BG)
|
||||
(seq shadows) (bit-or SECTION-SHADOWS)
|
||||
(= shape-type :group) (bit-or SECTION-MASKED)
|
||||
(some? bool-type) (bit-or SECTION-BOOL-TYPE)
|
||||
(some? grow-type) (bit-or SECTION-GROW-TYPE)
|
||||
flex? (bit-or SECTION-FLEX)
|
||||
layout-item? (bit-or SECTION-LAYOUT-ITEM)
|
||||
include-fills-strokes? (bit-or SECTION-FILLS)
|
||||
include-fills-strokes? (bit-or SECTION-STROKES))
|
||||
|
||||
offset (write-base-props! dview offset shape)
|
||||
_ (buf/write-u32 dview offset mask)
|
||||
offset (+ offset 4)
|
||||
|
||||
offset (let [o offset]
|
||||
(buf/write-u32 dview o (count children))
|
||||
(reduce (fn [o id] (write-uuid! dview o id))
|
||||
(+ o 4)
|
||||
children))
|
||||
|
||||
offset (cond-> offset
|
||||
(some? blur)
|
||||
(as-> o (write-blur! dview o blur)))
|
||||
|
||||
offset (cond-> offset
|
||||
(some? bg-blur)
|
||||
(as-> o (write-blur! dview o bg-blur)))
|
||||
|
||||
offset (cond-> offset
|
||||
(seq shadows)
|
||||
(as-> o
|
||||
(do
|
||||
(buf/write-u32 dview o (count shadows))
|
||||
(reduce (fn [o s] (write-shadow! dview o s))
|
||||
(+ o 4)
|
||||
shadows))))
|
||||
|
||||
offset (cond-> offset
|
||||
(= shape-type :group)
|
||||
(as-> o
|
||||
(do (buf/write-u8 dview o (if masked? 1 0))
|
||||
(+ o 4))))
|
||||
|
||||
offset (cond-> offset
|
||||
(some? bool-type)
|
||||
(as-> o
|
||||
(do (buf/write-u8 dview o (sr/translate-bool-type bool-type))
|
||||
(+ o 4))))
|
||||
|
||||
offset (cond-> offset
|
||||
(some? grow-type)
|
||||
(as-> o
|
||||
(do (buf/write-u8 dview o (sr/translate-grow-type grow-type))
|
||||
(+ o 4))))
|
||||
|
||||
;; FLEX before LAYOUT-ITEM (Rust clears layout on flex)
|
||||
offset (cond-> offset
|
||||
flex?
|
||||
(as-> o (write-flex! dview o shape)))
|
||||
|
||||
offset (cond-> offset
|
||||
layout-item?
|
||||
(as-> o (write-layout-item! dview o shape)))
|
||||
|
||||
offset (cond-> offset
|
||||
include-fills-strokes?
|
||||
(as-> o (write-fills-section! dview o (get shape :fills))))
|
||||
|
||||
offset (cond-> offset
|
||||
include-fills-strokes?
|
||||
(as-> o (write-strokes-section! dview o strokes)))]
|
||||
offset))
|
||||
|
||||
(defn- payload-byte-size
|
||||
[shape {:keys [include-layout? include-fills-strokes?]
|
||||
:or {include-layout? false include-fills-strokes? false}}]
|
||||
(let [children (into [] (filter uuid?) (get shape :shapes))
|
||||
shadows (or (get shape :shadow) [])
|
||||
shape-type (dm/get-prop shape :type)
|
||||
blur (get shape :blur)
|
||||
bg-blur (get shape :background-blur)
|
||||
flex? (and include-layout? (ctl/flex-layout? shape))
|
||||
fills-size (if include-fills-strokes?
|
||||
(types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) [])))
|
||||
0)
|
||||
strokes (when include-fills-strokes? (visible-strokes shape))
|
||||
strokes-size (if include-fills-strokes?
|
||||
(+ 4 (* (count strokes)
|
||||
(+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
|
||||
0)]
|
||||
(+ BASE-PROPS-SIZE
|
||||
4 ;; mask
|
||||
(+ 4 (* 16 (count children)))
|
||||
(if (some? blur) 8 0)
|
||||
(if (some? bg-blur) 8 0)
|
||||
(if (seq shadows) (+ 4 (* 24 (count shadows))) 0)
|
||||
(if (= shape-type :group) 4 0)
|
||||
(if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0)
|
||||
(if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0)
|
||||
(if flex? 32 0)
|
||||
(if include-layout? 40 0)
|
||||
fills-size
|
||||
strokes-size)))
|
||||
|
||||
(defn- encode-shape-record
|
||||
"Returns a Uint8Array: [u32 payload_len][payload]."
|
||||
[shape opts]
|
||||
(let [capacity (+ 4 (payload-byte-size shape opts))
|
||||
buffer (js/ArrayBuffer. capacity)
|
||||
dview (js/DataView. buffer)
|
||||
end (write-shape-payload! dview 4 shape opts)
|
||||
payload-len (- end 4)]
|
||||
(assert (= end capacity)
|
||||
(str "upload record size mismatch: wrote " end " expected " capacity))
|
||||
(buf/write-u32 dview 0 payload-len)
|
||||
(js/Uint8Array. buffer 0 end)))
|
||||
|
||||
(defn flush-shapes-batch!
|
||||
"Upload `shapes` as one `_set_shapes_batch` call.
|
||||
`opts` passed to each record writer (`:include-layout?`,
|
||||
`:include-fills-strokes?`)."
|
||||
[shapes opts]
|
||||
(when (and (wasm/live?) (seq shapes))
|
||||
(let [records (mapv #(encode-shape-record % opts) shapes)
|
||||
total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records)
|
||||
offset (mem/alloc total)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(buf/write-u32 dview offset (count records))
|
||||
(reduce (fn [o ^js u8]
|
||||
(.set heap u8 o)
|
||||
(+ o (.-byteLength u8)))
|
||||
(+ offset 4)
|
||||
records)
|
||||
(h/call wasm/internal-module "_set_shapes_batch")
|
||||
nil)))
|
||||
|
||||
(defn set-shape-upload!
|
||||
"Single-shape structural upload (enlarged blob, one FFI)."
|
||||
([shape]
|
||||
(set-shape-upload! shape {:include-layout? false}))
|
||||
([shape opts]
|
||||
(flush-shapes-batch! [shape] opts)))
|
||||
@@ -12,43 +12,29 @@
|
||||
- the workspace `app.render-wasm.api/set-object` (browser), and
|
||||
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
|
||||
|
||||
It applies only the properties that need no host-specific resources or driver:
|
||||
base props, children, blur, background blur, shadows, svg attrs, group mask,
|
||||
bool type, path/bool geometry and text grow type. The parts that DO differ by
|
||||
host are handled by each caller AFTER this runs:
|
||||
- fills / strokes (image bytes are fetched + uploaded differently),
|
||||
Structural attrs (base, children, blur, shadows, masked, bool, grow) go
|
||||
through the enlarged `_set_shapes_batch` upload. Path geometry stays on the
|
||||
chunked path FFI. Host-specific parts remain in each caller AFTER this runs:
|
||||
- fills / strokes image bytes (records may already be in cold-load batch),
|
||||
- text content (fonts),
|
||||
- svg-raw markup (browser renders it via React),
|
||||
- layout (grid/flex — workspace only).
|
||||
- svg-raw markup (browser React),
|
||||
- layout (grid/flex — workspace cold-load batches flex+item via upload;
|
||||
incremental edits still use `set-shape-layout` / `set-layout-data`).
|
||||
|
||||
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
|
||||
dispatching per changed key through the same underlying `props` setters."
|
||||
(:require
|
||||
[app.common.render-wasm.api.props :as props]
|
||||
[app.common.render-wasm.api.shapes :as shapes]))
|
||||
[app.common.render-wasm.api.upload :as upload]))
|
||||
|
||||
(defn serialize-shape!
|
||||
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
|
||||
runs first because it selects the current shape (`use_shape`) the rest mutate."
|
||||
"Applies every host-independent WASM property of `shape`."
|
||||
[shape]
|
||||
(let [type (get shape :type)]
|
||||
(shapes/set-shape-base-props shape)
|
||||
(props/set-shape-children (get shape :shapes))
|
||||
(props/set-shape-blur (get shape :blur))
|
||||
(props/set-shape-background-blur (get shape :background-blur))
|
||||
(props/set-shape-shadows (get shape :shadow))
|
||||
(upload/set-shape-upload! shape {:include-layout? false})
|
||||
|
||||
(when (some? (get shape :svg-attrs))
|
||||
(props/set-shape-svg-attrs (get shape :svg-attrs)))
|
||||
|
||||
(when (= type :group)
|
||||
(props/set-masked (boolean (get shape :masked-group))))
|
||||
|
||||
(when (= type :bool)
|
||||
(props/set-shape-bool-type (get shape :bool-type)))
|
||||
|
||||
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
|
||||
(props/set-shape-path-content (get shape :content)))
|
||||
|
||||
(when (= type :text)
|
||||
(props/set-shape-grow-type (get shape :grow-type)))))
|
||||
(props/set-shape-path-content (get shape :content)))))
|
||||
@@ -216,6 +216,41 @@
|
||||
:else
|
||||
(get-instance-root objects (get objects (:parent-id shape)))))
|
||||
|
||||
(defn get-all-instance-roots
|
||||
"Given a list of shape ids and an objects tree, returns a set with the ids of
|
||||
all instance roots that are at, above or below any of the shapes identified by
|
||||
the given list. An instance root is a shape that has :component-root set to
|
||||
true (checked by ctk/instance-root?). There is at most one instance root in
|
||||
any subtree rooted at an instance root, so the downward search stops at the
|
||||
first instance root found in each branch. Uses a visited set to avoid
|
||||
reprocessing the same shapes."
|
||||
[objects shape-ids]
|
||||
(let [visited (atom #{})
|
||||
result (atom #{})]
|
||||
(letfn [(search-up [shape-id]
|
||||
(when-not (contains? @visited shape-id)
|
||||
(swap! visited conj shape-id)
|
||||
(let [shape (get objects shape-id)]
|
||||
(when-not (nil? shape)
|
||||
(if (ctk/instance-root? shape)
|
||||
(swap! result conj (:id shape))
|
||||
(when-not (cfh/root? shape)
|
||||
(when-let [parent-id (:parent-id shape)]
|
||||
(search-up parent-id))))))))
|
||||
(search-down [shape-id]
|
||||
(when-not (contains? @visited shape-id)
|
||||
(swap! visited conj shape-id)
|
||||
(let [shape (get objects shape-id)]
|
||||
(when-not (nil? shape)
|
||||
(if (ctk/instance-root? shape)
|
||||
(swap! result conj (:id shape))
|
||||
(doseq [child-id (:shapes shape)]
|
||||
(search-down child-id)))))))]
|
||||
(doseq [shape-id shape-ids]
|
||||
(search-up shape-id)
|
||||
(search-down shape-id))
|
||||
@result)))
|
||||
|
||||
(defn find-component-main
|
||||
"If the shape is a component main instance or is inside one, return that instance.
|
||||
Uses an iterative loop with cycle detection to prevent stack overflow on circular
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
schema:string
|
||||
schema:string]])
|
||||
|
||||
(def valid-permissions
|
||||
"Set of valid plugin permissions that can be granted to plugins."
|
||||
#{"content:read" "content:write"
|
||||
"library:read" "library:write"
|
||||
"comment:read" "comment:write"
|
||||
"clipboard:read" "clipboard:write"
|
||||
"user:read"
|
||||
"allow:downloads"
|
||||
"allow:localstorage"})
|
||||
|
||||
(def schema:permissions
|
||||
"Schema for plugin permissions - a set of valid permission strings."
|
||||
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
|
||||
|
||||
(def schema:registry-entry
|
||||
[:map
|
||||
[:plugin-id :string]
|
||||
@@ -36,7 +50,7 @@
|
||||
[:host :string]
|
||||
[:code :string]
|
||||
[:icon {:optional true} :string]
|
||||
[:permissions [:set :string]]])
|
||||
[:permissions schema:permissions]])
|
||||
|
||||
(def schema:plugin-registry
|
||||
[:map
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
[:variant-id {:optional true} ::sm/uuid]
|
||||
[:variant-properties {:optional true} [:vector schema:variant-property]]])
|
||||
|
||||
(def valid-variant-component?
|
||||
(sm/check-fn schema:variant-component))
|
||||
|
||||
(def schema:variant-shape
|
||||
"The root shape of the main instance of a variant component"
|
||||
[:map
|
||||
@@ -34,14 +37,17 @@
|
||||
[:variant-name {:optional true} :string]
|
||||
[:variant-error {:optional true} :string]])
|
||||
|
||||
(def valid-variant-shape?
|
||||
(sm/check-fn schema:variant-shape))
|
||||
|
||||
(def schema:variant-container
|
||||
"Is a board that contains all variant components of a variant set,
|
||||
for grouping them visually in the workspace"
|
||||
[:map
|
||||
[:is-variant-container {:optional true} :boolean]])
|
||||
|
||||
(def valid-variant-component?
|
||||
(sm/check-fn schema:variant-component))
|
||||
(def valid-variant-container?
|
||||
(sm/check-fn schema:variant-container))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -50,17 +56,41 @@
|
||||
(def property-max-length 60)
|
||||
(def value-prefix "Value ")
|
||||
|
||||
(defn variant-component?
|
||||
[component]
|
||||
(some? (:variant-id component)))
|
||||
|
||||
(defn variant-shape?
|
||||
[shape]
|
||||
(some? (:variant-id shape)))
|
||||
|
||||
(defn variant-container?
|
||||
[shape]
|
||||
(some? (:is-variant-container shape)))
|
||||
|
||||
(defn properties-to-name
|
||||
"Transform the properties into a name, with the values separated by comma"
|
||||
"Transform the properties into a name, with the values separated by comma, excluding the empty ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}] -> 'Button, Primary'"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(map :value)
|
||||
(remove str/empty?)
|
||||
(str/join ", ")))
|
||||
|
||||
(defn next-property-number
|
||||
"Returns the next property number, to avoid duplicates on the property names"
|
||||
"Returns the next property number, to avoid duplicates on the property names.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 3' :value 'y'}] -> 4"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(let [numbers (keep
|
||||
#(some->> (:name %) (re-find property-regex) second d/parse-integer)
|
||||
properties)
|
||||
@@ -69,38 +99,70 @@
|
||||
0)]
|
||||
(inc (max max-num (count properties)))))
|
||||
|
||||
(defn add-new-prop
|
||||
"Adds a new property with generated name and provided value to the existing props list."
|
||||
[props value]
|
||||
(conj props {:name (str property-prefix (next-property-number props))
|
||||
:value value}))
|
||||
(defn add-new-property
|
||||
"Adds a new property with generated name and provided value to the existing properties list.
|
||||
|
||||
(defn add-new-props
|
||||
"Adds new properties with generated names and provided values to the existing props list."
|
||||
[props values]
|
||||
(let [next-prop-num (next-property-number props)
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}] 'y' -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]"
|
||||
[properties value]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (string? value) (nil? value)))
|
||||
(conj properties {:name (str property-prefix (next-property-number properties))
|
||||
:value value}))
|
||||
|
||||
(defn add-new-properties
|
||||
"Adds new properties with generated names and provided values to the existing properties list.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}] ['a' 'b'] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'a'}
|
||||
{:name 'Property 3' :value 'b'}]"
|
||||
[properties values]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (sequential? values) (nil? values)))
|
||||
(let [next-prop-num (next-property-number properties)
|
||||
xf (map-indexed (fn [i v]
|
||||
{:name (str property-prefix (+ next-prop-num i))
|
||||
:value v}))]
|
||||
(into props xf values)))
|
||||
(into properties xf values)))
|
||||
|
||||
(defn path-to-properties
|
||||
"From a list of properties and a name with path, assign each token of the
|
||||
path as value of a different property"
|
||||
path as value of a different property. It can add blank properties if
|
||||
necessary, until the min-properties number is reached.
|
||||
|
||||
Example with min-properties=4:
|
||||
|
||||
'Button / Primary / Hover' -> [{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}
|
||||
{:name 'Property 3' :value 'Hover'}
|
||||
{:name 'Property 4' :value ''}]"
|
||||
([path properties]
|
||||
(path-to-properties path properties 0))
|
||||
([path properties min-props]
|
||||
([path properties min-properties]
|
||||
(assert (or (string? path) (nil? path)))
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (int? min-properties))
|
||||
(let [cpath (cpn/split-path path)
|
||||
total-props (max (count cpath) min-props)
|
||||
total-properties (max (count cpath) min-properties)
|
||||
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
|
||||
;; Add empty strings to the end of cpath to reach the minimum number of properties
|
||||
cpath (take total-props (concat cpath (repeat "")))
|
||||
cpath (take total-properties (concat cpath (repeat "")))
|
||||
remaining (drop (count properties) cpath)]
|
||||
(add-new-props assigned remaining))))
|
||||
(add-new-properties assigned remaining))))
|
||||
|
||||
(defn properties-map->formula
|
||||
"Transforms a map of properties to a formula of properties omitting the empty ones"
|
||||
"Transforms a map of properties to a formula of properties omitting the empty ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}] -> 'Property 1=Button, Property 2=Primary'"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(keep (fn [{:keys [name value]}]
|
||||
(when (not (str/blank? value))
|
||||
@@ -108,9 +170,15 @@
|
||||
(str/join ", ")))
|
||||
|
||||
(defn properties-formula->map
|
||||
"Transforms a formula of properties to a map of properties"
|
||||
[s]
|
||||
(->> (str/split s ",")
|
||||
"Transforms a formula of properties to a map of properties.
|
||||
|
||||
Example:
|
||||
|
||||
'Property 1=Button, Property 2=Primary' -> [{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}]"
|
||||
[formula]
|
||||
(assert (or (string? formula) (nil? formula)))
|
||||
(->> (str/split formula ",")
|
||||
(mapv #(str/split % "=" 2))
|
||||
(filter (fn [[_ v]] (not (str/blank? v))))
|
||||
(mapv (fn [[k v]]
|
||||
@@ -118,9 +186,15 @@
|
||||
:value (str/trim v)}))))
|
||||
|
||||
(defn valid-properties-formula?
|
||||
"Checks if a formula is valid"
|
||||
[s]
|
||||
(->> (str/split s ",")
|
||||
"Checks if a formula is valid.
|
||||
|
||||
Example:
|
||||
|
||||
'Property 1=Button, Property 2=Primary' -> true
|
||||
'Property 1=Button, Property 2' -> false"
|
||||
[formula]
|
||||
(assert (or (string? formula) (nil? formula)))
|
||||
(->> (str/split formula ",")
|
||||
(mapv #(str/split % "=" 2))
|
||||
(every? #(and (= 2 (count %))
|
||||
(not (str/blank? (first %)))
|
||||
@@ -128,22 +202,47 @@
|
||||
(< (count (second %)) property-max-length)))))
|
||||
|
||||
(defn find-properties-to-remove
|
||||
"Compares two property maps to find which properties should be removed"
|
||||
[prev-props upd-props]
|
||||
(let [upd-names (set (map :name upd-props))]
|
||||
(filterv #(not (contains? upd-names (:name %))) prev-props)))
|
||||
"Compares two property maps to find which properties should be removed.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'x'}] -> [{:name 'Property 2' :value 'y'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(let [upd-names (set (map :name upd-properties))]
|
||||
(filterv #(not (contains? upd-names (:name %))) prev-properties)))
|
||||
|
||||
(defn find-properties-to-update
|
||||
"Compares two property maps to find which properties should be updated"
|
||||
[prev-props upd-props]
|
||||
"Compares two property maps to find which properties should be updated.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'new-x'}
|
||||
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 1' :value 'new-x'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(filterv #(some (fn [prop] (and (= (:name %) (:name prop))
|
||||
(not= (:value %) (:value prop)))) prev-props) upd-props))
|
||||
(not= (:value %) (:value prop)))) prev-properties) upd-properties))
|
||||
|
||||
(defn find-properties-to-add
|
||||
"Compares two property maps to find which properties should be added"
|
||||
[prev-props upd-props]
|
||||
(let [prev-names (set (map :name prev-props))]
|
||||
(filterv #(not (contains? prev-names (:name %))) upd-props)))
|
||||
"Compares two property maps to find which properties should be added.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 2' :value 'y'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(let [prev-names (set (map :name prev-properties))]
|
||||
(filterv #(not (contains? prev-names (:name %))) upd-properties)))
|
||||
|
||||
(defn- split-base-name-and-number
|
||||
"Extract the number in parentheses from an item, if present, and return both the base name and the number"
|
||||
@@ -165,8 +264,15 @@
|
||||
|
||||
(defn update-number-in-repeated-item
|
||||
"Add, keep or update a number in parentheses for a given item, if necessary, depending on the items
|
||||
already present in a list, to avoid repetitions"
|
||||
already present in a list, to avoid repetitions.
|
||||
|
||||
Example:
|
||||
|
||||
['Property'] 'Property' -> 'Property (1)'
|
||||
['Property' 'Property (1)'] 'Property' -> 'Property (2)'"
|
||||
[items item]
|
||||
(assert (or (sequential? items) (nil? items)))
|
||||
(assert (or (string? item) (nil? item)))
|
||||
(let [names (group-numbers-by-base-name items)
|
||||
[base num] (split-base-name-and-number item)
|
||||
nums-taken (get names base #{})]
|
||||
@@ -176,25 +282,46 @@
|
||||
(str base (when (pos? n) (str " (" n ")")))))))
|
||||
|
||||
(defn update-number-in-repeated-prop-names
|
||||
"Add, keep or update a number for each prop name depending on the previous ones"
|
||||
[props]
|
||||
(->> props
|
||||
"Add, keep or update a number for each prop name depending on the previous ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property' :value 'x'}
|
||||
{:name 'Property' :value 'y'}] -> [{:name 'Property' :value 'x'}
|
||||
{:name 'Property (1)' :value 'y'}]"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(reduce (fn [acc prop]
|
||||
(conj acc {:name (update-number-in-repeated-item (mapv :name acc) (:name prop))
|
||||
:value (:value prop)}))
|
||||
[])))
|
||||
|
||||
(defn find-index-for-property-name
|
||||
"Finds the index of a name in a property map"
|
||||
[props name]
|
||||
"Finds the index of a name in a property map.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}] 'Property 2' -> 1"
|
||||
[properties name]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (string? name) (nil? name)))
|
||||
(some (fn [[idx prop]]
|
||||
(when (= (:name prop) name)
|
||||
idx))
|
||||
(map-indexed vector props)))
|
||||
(map-indexed vector properties)))
|
||||
|
||||
(defn remove-prefix
|
||||
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name"
|
||||
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name.
|
||||
|
||||
Example:
|
||||
|
||||
'Button / Primary' 'Button' -> 'Primary'
|
||||
'Button / Primary' 'Other' -> 'Button / Primary'"
|
||||
[name prefix]
|
||||
(assert (or (string? name) (nil? name)))
|
||||
(assert (or (string? prefix) (nil? prefix)))
|
||||
(let [long-name (str prefix " / ")]
|
||||
(cond
|
||||
(str/starts-with? name long-name)
|
||||
@@ -210,22 +337,22 @@
|
||||
(map :name))
|
||||
|
||||
(defn- matching-indices
|
||||
[props1 props2]
|
||||
(let [names-in-p2 (into #{} xf:map-name props2)
|
||||
[properties1 properties2]
|
||||
(let [names-in-p2 (into #{} xf:map-name properties2)
|
||||
xform (comp
|
||||
(map-indexed (fn [index {:keys [name]}]
|
||||
(when (contains? names-in-p2 name)
|
||||
index)))
|
||||
(filter some?))]
|
||||
(into #{} xform props1)))
|
||||
(into #{} xform properties1)))
|
||||
|
||||
(defn- find-index-by-name
|
||||
"Returns the index of the first item in props with the given name, or nil if not found."
|
||||
[name props]
|
||||
"Returns the index of the first item in properties with the given name, or nil if not found."
|
||||
[name properties]
|
||||
(some (fn [[idx item]]
|
||||
(when (= (:name item) name)
|
||||
idx))
|
||||
(map-indexed vector props)))
|
||||
(map-indexed vector properties)))
|
||||
|
||||
(defn- next-valid-position
|
||||
"Returns the first non-negative integer not present in the used-pos set."
|
||||
@@ -236,42 +363,64 @@
|
||||
p)))
|
||||
|
||||
(defn- find-position
|
||||
"Returns the index of the property with the given name in `props`,
|
||||
"Returns the index of the property with the given name in `properties`,
|
||||
or the next available index not in `used-pos` if not found."
|
||||
[name props used-pos]
|
||||
(or (find-index-by-name name props)
|
||||
[name properties used-pos]
|
||||
(or (find-index-by-name name properties)
|
||||
(next-valid-position used-pos)))
|
||||
|
||||
(defn merge-properties
|
||||
"Merges props2 into props1 with the following rules:
|
||||
- For each property p2 in props2:
|
||||
"Merges properties2 into properties1 with the following rules:
|
||||
- For each property p2 in properties2:
|
||||
- Skip it if its value is empty.
|
||||
- If props1 contains a property with the same name, update its value with that of p2.
|
||||
- Otherwise, assign p2's value to the first unused property in props1. A property is considered used if:
|
||||
- Its name exists in both props1 and props2, or
|
||||
- If properties1 contains a property with the same name, update its value with that of p2.
|
||||
- Otherwise, assign p2's value to the first unused property in properties1. A property is considered used if:
|
||||
- Its name exists in both properties1 and properties2, or
|
||||
- Its value has already been updated during the merge.
|
||||
- If no unused properties are available in props1, append a new property with a default name and p2's value."
|
||||
[props1 props2]
|
||||
(let [props2 (remove #(str/empty? (:value %)) props2)]
|
||||
- If no unused properties are available in properties1, append a new property with a default name and p2's value.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'a'}
|
||||
{:name 'Property 2' :value 'b'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}
|
||||
{:name 'Property 3' :value 'z'}] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}
|
||||
{:name 'Property 3' :value 'z'}]"
|
||||
[properties1 properties2]
|
||||
(assert (or (sequential? properties1) (nil? properties1)))
|
||||
(assert (or (sequential? properties2) (nil? properties2)))
|
||||
(let [properties2 (remove #(str/empty? (:value %)) properties2)]
|
||||
(-> (reduce
|
||||
(fn [{:keys [props used-pos]} prop]
|
||||
(let [pos (find-position (:name prop) props used-pos)
|
||||
(fn [{:keys [properties used-pos]} prop]
|
||||
(let [pos (find-position (:name prop) properties used-pos)
|
||||
used-pos (conj used-pos pos)]
|
||||
(if (< pos (count props))
|
||||
{:props (assoc-in (vec props) [pos :value] (:value prop)) :used-pos used-pos}
|
||||
{:props (add-new-prop props (:value prop)) :used-pos used-pos})))
|
||||
{:props (vec props1) :used-pos (matching-indices props1 props2)}
|
||||
props2)
|
||||
:props)))
|
||||
(if (< pos (count properties))
|
||||
{:properties (assoc-in (vec properties) [pos :value] (:value prop)) :used-pos used-pos}
|
||||
{:properties (add-new-property properties (:value prop)) :used-pos used-pos})))
|
||||
{:properties (vec properties1) :used-pos (matching-indices properties1 properties2)}
|
||||
properties2)
|
||||
:properties)))
|
||||
|
||||
(defn compare-properties
|
||||
"Compares vectors of properties keeping the value if it is the same for all
|
||||
or setting a custom value where their values do not coincide"
|
||||
([props-list]
|
||||
(compare-properties props-list nil))
|
||||
or setting a custom value where their values do not coincide.
|
||||
|
||||
([props-list distinct-mark]
|
||||
(let [grouped (group-by :name (apply concat props-list))
|
||||
Example:
|
||||
|
||||
[[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'z'}]] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value nil}]"
|
||||
([properties-list]
|
||||
(compare-properties properties-list nil))
|
||||
|
||||
([properties-list distinct-mark]
|
||||
(assert (or (sequential? properties-list) (nil? properties-list)))
|
||||
(assert (or (string? distinct-mark) (nil? distinct-mark)))
|
||||
(let [grouped (group-by :name (apply concat properties-list))
|
||||
check-values (fn [values]
|
||||
(let [vals (map :value values)]
|
||||
(if (apply = vals)
|
||||
@@ -281,33 +430,37 @@
|
||||
{:name name :value (check-values values)})
|
||||
grouped))))
|
||||
|
||||
(defn same-variant?
|
||||
"Determines if all elements belong to the same variant"
|
||||
[components]
|
||||
(let [variant-ids (distinct (map :variant-id components))
|
||||
not-blank? (complement str/blank?)]
|
||||
(and
|
||||
(= 1 (count variant-ids))
|
||||
(not-blank? (first variant-ids)))))
|
||||
(defn properties-distance
|
||||
"Computes a weighted distance between two property lists `properties1` and `properties2`.
|
||||
Latter properties weight less that previous ones.
|
||||
|
||||
(defn distance
|
||||
"Computes a weighted distance between two property lists `props1` and `props2`.
|
||||
Latter properties weight less that previous ones"
|
||||
[props1 props2]
|
||||
(let [total-num-props (count props1)
|
||||
Example:
|
||||
|
||||
[{:name 'type' :value 'primary'}
|
||||
{:name 'status' :value 'default'}]
|
||||
[{:name 'type' :value 'primary'}
|
||||
{:name 'status' :value 'hover'}] -> 1.0"
|
||||
[properties1 properties2]
|
||||
(assert (or (sequential? properties1) (nil? properties1)))
|
||||
(assert (or (sequential? properties2) (nil? properties2)))
|
||||
(let [total-num-properties (count properties1)
|
||||
xform (map-indexed
|
||||
(fn [idx [p1 p2]]
|
||||
(if (not= p1 p2)
|
||||
(math/pow 2 (- total-num-props idx))
|
||||
(math/pow 2 (- total-num-properties idx))
|
||||
0)))]
|
||||
(transduce
|
||||
xform
|
||||
+
|
||||
(map vector props1 props2))))
|
||||
(map vector properties1 properties2))))
|
||||
|
||||
(defn variant-name-to-name
|
||||
"Transforms a variant-name (its properties values) into a standard name:
|
||||
the real name of the shape joined by the properties values separated by '/'"
|
||||
the real name of the shape joined by the properties values separated by '/'.
|
||||
|
||||
Example:
|
||||
|
||||
{:name 'Button' :variant-name 'Primary, Hover'} -> 'Button / Primary / Hover'"
|
||||
[variant]
|
||||
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
|
||||
|
||||
@@ -317,8 +470,13 @@
|
||||
["true" "false"]])
|
||||
|
||||
(defn find-boolean-pair
|
||||
"Given a vector, return a map that contains the boolean equivalency if the values match
|
||||
with any of the boolean pairs. Returns nil if none match."
|
||||
"Given a collection, return a map that contains the boolean equivalency if the values match
|
||||
with any of the boolean pairs. Returns nil if none match.
|
||||
|
||||
Example:
|
||||
|
||||
['on' 'off'] -> {'on' true 'off' false}
|
||||
['foo' 'bar'] -> nil"
|
||||
[[a b :as v]]
|
||||
(let [a' (-> a str/trim str/lower)
|
||||
b' (-> b str/trim str/lower)]
|
||||
@@ -330,3 +488,18 @@
|
||||
(= a' f)) {b true a false}
|
||||
:else nil))
|
||||
boolean-pairs))))
|
||||
|
||||
(defn same-variant?
|
||||
"Determines if all elements belong to the same variant.
|
||||
|
||||
Example:
|
||||
|
||||
[{:variant-id 'abc'} {:variant-id 'abc'}] -> true
|
||||
[{:variant-id 'abc'} {:variant-id 'def'}] -> false"
|
||||
[components]
|
||||
(assert (or (sequential? components) (nil? components)))
|
||||
(let [variant-ids (distinct (map :variant-id components))
|
||||
not-blank? (complement str/blank?)]
|
||||
(and
|
||||
(= 1 (count variant-ids))
|
||||
(not-blank? (first variant-ids)))))
|
||||
@@ -0,0 +1,165 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns common-tests.files.variant-test
|
||||
(:require
|
||||
[app.common.files.variant :as fv]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.variants :as thv]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
|
||||
;; ============================================================
|
||||
;; find-variant-components
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest find-variant-components-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/find-variant-components data (uuid/next))
|
||||
[]))
|
||||
(t/is (= (fv/find-variant-components data objects (uuid/next))
|
||||
[]))))
|
||||
|
||||
(t/deftest find-variant-components-non-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/find-variant-components data (thi/id :m01))
|
||||
[]))
|
||||
(t/is (= (fv/find-variant-components data objects (thi/id :m01))
|
||||
[]))))
|
||||
|
||||
(t/deftest find-variant-components-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/find-variant-components data objects (thi/id :v01))]
|
||||
(t/is (= (count result) 2))
|
||||
(t/is (every? #(contains? % :id) result))
|
||||
(t/is (every? #(contains? % :variant-id) result))))
|
||||
|
||||
(t/deftest find-variant-components-single-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/find-variant-components data objects (thi/id :v01))]
|
||||
;; Verify the order is maintained (reversed from shapes order)
|
||||
(t/is (= (:variant-id (first result)) (thi/id :v01)))
|
||||
(t/is (= (:variant-id (second result)) (thi/id :v01)))))
|
||||
|
||||
;; ============================================================
|
||||
;; extract-properties-values
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest extract-properties-values-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/extract-properties-values data objects (uuid/next))
|
||||
[]))))
|
||||
|
||||
(t/deftest extract-properties-values-non-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/extract-properties-values data objects (thi/id :m01))
|
||||
[]))))
|
||||
|
||||
(t/deftest extract-properties-values-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/extract-properties-values data objects (thi/id :v01))]
|
||||
(t/is (seq result))
|
||||
(t/is (every? #(contains? % :name) result))
|
||||
(t/is (every? #(contains? % :value) result))
|
||||
(t/is (= (:name (first result)) "Property 1"))
|
||||
(t/is (= (set (:value (first result))) #{"Value1" "Value2"}))))
|
||||
|
||||
(t/deftest extract-properties-values-two-properties
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/extract-properties-values data objects (thi/id :v01))]
|
||||
(t/is (= (count result) 2))
|
||||
(t/is (= (set (map :name result)) #{"Property 1" "Property 2"}))))
|
||||
|
||||
;; ============================================================
|
||||
;; is-secondary-variant?
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest is-secondary-variant-primary
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
(t/deftest is-secondary-variant-secondary
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c02)]
|
||||
(t/is (fv/is-secondary-variant? data component))))
|
||||
|
||||
(t/deftest is-secondary-variant-not-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
(t/deftest is-secondary-variant-no-shapes
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
;; ============================================================
|
||||
;; get-primary-variant
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest get-primary-variant-nil
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)]
|
||||
(t/is (nil? (fv/get-primary-variant data nil)))))
|
||||
|
||||
(t/deftest get-primary-variant-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
|
||||
(t/is (nil? (fv/get-primary-variant data component)))))
|
||||
|
||||
(t/deftest get-primary-variant-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)
|
||||
result (fv/get-primary-variant data component)]
|
||||
(t/is (some? result))
|
||||
(t/is (contains? result :id))
|
||||
(t/is (contains? result :component-id))))
|
||||
@@ -23,6 +23,7 @@
|
||||
[common-tests.files-migrations-test]
|
||||
[common-tests.files.shapes-builder-test]
|
||||
[common-tests.files.validate-test]
|
||||
[common-tests.files.variant-test]
|
||||
[common-tests.geom-align-test]
|
||||
[common-tests.geom-bounds-layout-nil-test]
|
||||
[common-tests.geom-bounds-map-test]
|
||||
@@ -87,6 +88,7 @@
|
||||
[common-tests.types.token-test]
|
||||
[common-tests.types.tokens-lib-test]
|
||||
[common-tests.types.tokens-status-test]
|
||||
[common-tests.types.variant-test]
|
||||
[common-tests.undo-stack-test]
|
||||
[common-tests.uuid-test]))
|
||||
|
||||
@@ -102,6 +104,7 @@
|
||||
'common-tests.files-migrations-0026-test
|
||||
'common-tests.files-migrations-test
|
||||
'common-tests.files.validate-test
|
||||
'common-tests.files.variant-test
|
||||
'common-tests.geom-align-test
|
||||
'common-tests.geom-bounds-layout-nil-test
|
||||
'common-tests.geom-bounds-map-test
|
||||
@@ -142,6 +145,7 @@
|
||||
'common-tests.logic.token-test
|
||||
'common-tests.logic.variants-switch-test
|
||||
'common-tests.math-test
|
||||
'common-tests.types.variant-test
|
||||
'common-tests.media-test
|
||||
'common-tests.path-names-test
|
||||
'common-tests.record-test
|
||||
|
||||
@@ -7,10 +7,238 @@
|
||||
(ns common-tests.types.variant-test
|
||||
(:require
|
||||
[app.common.types.variant :as ctv]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest variant-component
|
||||
(t/is (not (ctv/variant-component? nil)))
|
||||
(t/is (not (ctv/variant-component? {})))
|
||||
(t/is (ctv/variant-component? {:variant-id (uuid/next)})))
|
||||
|
||||
(t/deftest variant-distance01
|
||||
(t/deftest variant-shape
|
||||
(t/is (not (ctv/variant-shape? nil)))
|
||||
(t/is (not (ctv/variant-shape? {})))
|
||||
(t/is (ctv/variant-shape? {:variant-id (uuid/next)})))
|
||||
|
||||
(t/deftest variant-container
|
||||
(t/is (not (ctv/variant-container? nil)))
|
||||
(t/is (not (ctv/variant-container? {})))
|
||||
(t/is (ctv/variant-container? {:is-variant-container true})))
|
||||
|
||||
(t/deftest properties-to-name-test
|
||||
(t/is (= "" (ctv/properties-to-name [])))
|
||||
(t/is (= "" (ctv/properties-to-name nil)))
|
||||
(t/is (= "Button, Primary" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}])))
|
||||
(t/is (= "Button" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value ""}]))))
|
||||
|
||||
(t/deftest next-property-number-test
|
||||
(t/is (= 1 (ctv/next-property-number [])))
|
||||
(t/is (= 1 (ctv/next-property-number nil)))
|
||||
(t/is (= 2 (ctv/next-property-number [{:name "Property 1" :value "x"}])))
|
||||
(t/is (= 4 (ctv/next-property-number [{:name "Property 3" :value "x"}])))
|
||||
(t/is (= 3 (ctv/next-property-number [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest add-new-property-test
|
||||
(t/is (= [{:name "Property 1" :value "x"}]
|
||||
(ctv/add-new-property [] "x")))
|
||||
(t/is (= [{:name "Property 1" :value "x"}]
|
||||
(ctv/add-new-property nil "x")))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/add-new-property [{:name "Property 1" :value "x"}] "y"))))
|
||||
|
||||
(t/deftest add-new-properties-test
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
|
||||
(ctv/add-new-properties [] ["a" "b"])))
|
||||
(t/is (= '({:name "Property 2" :value "b"} {:name "Property 1" :value "a"})
|
||||
(ctv/add-new-properties nil ["a" "b"])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "a"} {:name "Property 3" :value "b"}]
|
||||
(ctv/add-new-properties [{:name "Property 1" :value "x"}] ["a" "b"]))))
|
||||
|
||||
(t/deftest path-to-properties-test
|
||||
(t/is (= [] (ctv/path-to-properties "" [])))
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
|
||||
(ctv/path-to-properties "a / b" nil)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}
|
||||
{:name "Property 3" :value "Hover"}]
|
||||
(ctv/path-to-properties "Button / Primary / Hover" [])))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}
|
||||
{:name "Property 3" :value "Hover"}
|
||||
{:name "Property 4" :value ""}]
|
||||
(ctv/path-to-properties "Button / Primary / Hover" [] 4)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}]
|
||||
(ctv/path-to-properties "Button / Primary" [{:name "Property 1" :value "old"}
|
||||
{:name "Property 2" :value "old2"}]))))
|
||||
|
||||
(t/deftest properties-map->formula-test
|
||||
(t/is (= "" (ctv/properties-map->formula [])))
|
||||
(t/is (= "" (ctv/properties-map->formula nil)))
|
||||
(t/is (= "Property 1=Button, Property 2=Primary"
|
||||
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}])))
|
||||
(t/is (= "Property 1=Button"
|
||||
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value ""}]))))
|
||||
|
||||
(t/deftest properties-formula->map-test
|
||||
(t/is (= [] (ctv/properties-formula->map "")))
|
||||
(t/is (= [] (ctv/properties-formula->map nil)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"} {:name "Property 2" :value "Primary"}]
|
||||
(ctv/properties-formula->map "Property 1=Button, Property 2=Primary")))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}]
|
||||
(ctv/properties-formula->map "Property 1=Button, Property 2="))))
|
||||
|
||||
(t/deftest valid-properties-formula?-test
|
||||
(t/is (= true (ctv/valid-properties-formula? "Property 1=Button, Property 2=Primary")))
|
||||
(t/is (= false (ctv/valid-properties-formula? "")))
|
||||
(t/is (= true (ctv/valid-properties-formula? nil)))
|
||||
(t/is (= false (ctv/valid-properties-formula? "Property 1=Button, Property 2"))))
|
||||
|
||||
(t/deftest find-properties-to-remove-test
|
||||
(t/is (= [] (ctv/find-properties-to-remove [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-remove nil nil)))
|
||||
(t/is (= [{:name "Property 3" :value "z"}]
|
||||
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 3" :value "z"}]))))
|
||||
|
||||
(t/deftest find-properties-to-update-test
|
||||
(t/is (= [] (ctv/find-properties-to-update [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-update nil nil)))
|
||||
(t/is (= [{:name "Property 1" :value "new-x"}]
|
||||
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "new-x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "new-x"} {:name "Property 2" :value "new-y"}]
|
||||
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "new-x"}
|
||||
{:name "Property 2" :value "new-y"}]))))
|
||||
|
||||
(t/deftest find-properties-to-add-test
|
||||
(t/is (= [] (ctv/find-properties-to-add [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-add nil nil)))
|
||||
(t/is (= [{:name "Property 3" :value "z"}]
|
||||
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}])))
|
||||
(t/is (= [{:name "Property 2" :value "y"}]
|
||||
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest update-number-in-repeated-item-test
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item [] "Property")))
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item nil "Property")))
|
||||
(t/is (= "Property (1)" (ctv/update-number-in-repeated-item ["Property"] "Property")))
|
||||
(t/is (= "Property (2)" (ctv/update-number-in-repeated-item ["Property" "Property (1)"] "Property")))
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item ["Other"] "Property"))))
|
||||
|
||||
(t/deftest update-number-in-repeated-prop-names-test
|
||||
(t/is (= [] (ctv/update-number-in-repeated-prop-names [])))
|
||||
(t/is (= [] (ctv/update-number-in-repeated-prop-names nil)))
|
||||
(t/is (= [{:name "Property" :value "x"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}])))
|
||||
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
|
||||
{:name "Property" :value "y"}])))
|
||||
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"} {:name "Property (2)" :value "z"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
|
||||
{:name "Property" :value "y"}
|
||||
{:name "Property" :value "z"}]))))
|
||||
|
||||
(t/deftest find-index-for-property-name-test
|
||||
(t/is (= nil (ctv/find-index-for-property-name [] "Property 1")))
|
||||
(t/is (= nil (ctv/find-index-for-property-name nil "Property 1")))
|
||||
(t/is (= 0 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 1")))
|
||||
(t/is (= 1 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}] "Property 2")))
|
||||
(t/is (= nil (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 3"))))
|
||||
|
||||
(t/deftest remove-prefix-test
|
||||
(t/is (= "name" (ctv/remove-prefix "name" "")))
|
||||
(t/is (= "name" (ctv/remove-prefix "name" nil)))
|
||||
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button")))
|
||||
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button / ")))
|
||||
(t/is (= "Button / Primary" (ctv/remove-prefix "Button / Primary" "Other"))))
|
||||
|
||||
(t/deftest merge-properties-test
|
||||
(t/is (= [] (ctv/merge-properties [] [])))
|
||||
(t/is (= [] (ctv/merge-properties nil nil)))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"} {:name "Property 3" :value "z"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}])))
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "y"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest compare-properties-test
|
||||
(t/is (= [] (ctv/compare-properties [])))
|
||||
(t/is (= [] (ctv/compare-properties nil)))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value nil}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "z"}]])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "*"}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "z"}]]
|
||||
"*"))))
|
||||
|
||||
(t/deftest variant-name-to-name-test
|
||||
(t/is (= "Button / Primary / Hover" (ctv/variant-name-to-name {:name "Button" :variant-name "Primary, Hover"})))
|
||||
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name ""})))
|
||||
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name nil})))
|
||||
(t/is (= "" (ctv/variant-name-to-name {:name "" :variant-name ""})))
|
||||
(t/is (= nil (ctv/variant-name-to-name {:name nil :variant-name nil}))))
|
||||
|
||||
(t/deftest find-boolean-pair-test
|
||||
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["on" "off"])))
|
||||
(t/is (= {"yes" true "no" false} (ctv/find-boolean-pair ["yes" "no"])))
|
||||
(t/is (= {"true" true "false" false} (ctv/find-boolean-pair ["true" "false"])))
|
||||
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["off" "on"])))
|
||||
(t/is (= {"ON" true "OFF" false} (ctv/find-boolean-pair ["ON" "OFF"])))
|
||||
(t/is (= nil (ctv/find-boolean-pair ["foo" "bar"])))
|
||||
(t/is (= nil (ctv/find-boolean-pair nil)))
|
||||
(t/is (= nil (ctv/find-boolean-pair ["on"]))))
|
||||
|
||||
(t/deftest same-variant?-test
|
||||
(t/is (= false (ctv/same-variant? [])))
|
||||
(t/is (= false (ctv/same-variant? nil)))
|
||||
(t/is (= true (ctv/same-variant? [{:variant-id "abc"}])))
|
||||
(t/is (= true (ctv/same-variant? [{:variant-id "abc"} {:variant-id "abc"}])))
|
||||
(t/is (= false (ctv/same-variant? [{:variant-id "abc"} {:variant-id "def"}])))
|
||||
(t/is (= false (ctv/same-variant? [{:variant-id ""} {:variant-id ""}]))))
|
||||
|
||||
(t/deftest properties-distance01
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: primary, hover, squared, blue, dark
|
||||
;;c3: primary, default, squared, blue, light
|
||||
@@ -35,12 +263,11 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "light"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)]
|
||||
(t/is (< dist3 dist2))))
|
||||
|
||||
|
||||
(t/deftest variant-distance02
|
||||
(t/deftest properties-distance02
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: primary, hover, squared, red, dark
|
||||
;;c3: secondary, hover, rounded, blue, dark
|
||||
@@ -65,11 +292,11 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "dark"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)]
|
||||
(t/is (< dist2 dist3))))
|
||||
|
||||
(t/deftest variant-distance03
|
||||
(t/deftest properties-distance03
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: secondary, default, rounded, blue, light
|
||||
;;c3: secondary, hover, squared, blue, dark
|
||||
@@ -101,12 +328,18 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "dark"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)
|
||||
dist4 (ctv/distance target props4)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)
|
||||
dist4 (ctv/properties-distance target props4)]
|
||||
(t/is (< dist2 dist4))
|
||||
(t/is (< dist4 dist3))))
|
||||
|
||||
(t/deftest properties-distance04
|
||||
(t/is (= 0 (ctv/properties-distance [] [])))
|
||||
(t/is (= 0 (ctv/properties-distance nil nil)))
|
||||
(t/is (= 0 (ctv/properties-distance [{:name "a" :value "x"}] [{:name "a" :value "x"}])))
|
||||
(t/is (= 2.0 (ctv/properties-distance [{:name "a" :value "x"} {:name "b" :value "y"}] [{:name "a" :value "x"} {:name "b" :value "z"}]))))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ x-body-size: &penpot-http-body-size
|
||||
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
|
||||
## (eg http sessions, or invitations) are derived.
|
||||
##
|
||||
## We recommend to use a trully randomly generated
|
||||
## We recommend to use a truly randomly generated
|
||||
## 512 bits base64 encoded string here. You can generate one with:
|
||||
##
|
||||
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
# - "443:443"
|
||||
|
||||
penpot-frontend:
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
ports:
|
||||
- 9001:8080
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
@@ -180,13 +180,13 @@ services:
|
||||
PENPOT_SMTP_SSL: "false"
|
||||
|
||||
penpot-mcp:
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
networks:
|
||||
- penpot
|
||||
|
||||
penpot-exporter:
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
|
||||
restart: always
|
||||
|
||||
depends_on:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user