Compare commits

...

4 Commits

Author SHA1 Message Date
Andrey Antukh
72ee0ba409 ⬆️ Update storybook and fix compatibility issues 2025-12-29 17:59:52 +01:00
Andrey Antukh
56d610ddfc 🔥 Remove npx prefix on package.json scripts 2025-12-29 14:29:25 +01:00
Andrey Antukh
7895b8579b 🔧 Add plugins runtime ci job 2025-12-29 14:23:32 +01:00
Andrey Antukh
4564a43bc4 🎉 Import penpot-plugins repository
As commit 819a549e4928d2b1fa98e52bee82d59aec0f70d8
2025-12-29 14:13:49 +01:00
294 changed files with 51591 additions and 528 deletions

View File

@@ -51,6 +51,49 @@ jobs:
run: |
./scripts/test
test-plugins:
name: Plugins Runtime Linter & Tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install deps
working-directory: ./plugins
run: npm ci
shell: bash
- name: Run Lint
working-directory: ./plugins
run: npm run lint
- name: Run Format Check
working-directory: ./plugins
run: npm run format:check
- name: Run Test
working-directory: ./plugins
run: npm run test
- name: Build runtime
working-directory: ./plugins
run: npm run build
- name: Build plugins
working-directory: ./plugins
run: npm run build:plugins
- name: Build styles
working-directory: ./plugins
run: npm run build:styles-example
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
@@ -67,6 +110,8 @@ jobs:
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components

2
.nvmrc
View File

@@ -1 +1 @@
v22.19.0
v22.21.1

View File

@@ -1,3 +1,5 @@
import { defineConfig } from 'vite';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
@@ -5,18 +7,38 @@ const config = {
addons: [
"@storybook/addon-themes",
"@storybook/addon-docs",
"@storybook/addon-vitest"
"@storybook/addon-vitest",
],
core: {
builder: "@storybook/builder-vite",
options: {
viteConfigPath: "../vite.config.js",
},
},
framework: {
name: "@storybook/react-vite",
options: {},
options: {
// fastRefresh: false,
}
},
docs: {},
async viteFinal(config) {
return defineConfig({
...config,
plugins: [
...(config.plugins ?? []),
{
name: 'force-full-reload-always',
apply: 'serve',
enforce: 'post',
handleHotUpdate(ctx) {
ctx.server.ws.send({
type: 'full-reload',
path: '*',
});
// returning [] tells Vite: “no modules handled”
return [];
},
}
]
});
}
};
export default config;

View File

@@ -1,6 +1,5 @@
import { withThemeByClassName } from "@storybook/addon-themes";
import Components from "@target/components";
import translations from "@public/translation.en.js";
Components.setDefaultTranslations(translations);

View File

@@ -58,15 +58,16 @@
"@penpot/plugins-runtime": "1.3.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "portal:./text-editor",
"@playwright/test": "1.52.0",
"@storybook/addon-docs": "10.0.4",
"@storybook/addon-themes": "10.0.4",
"@storybook/addon-vitest": "10.0.4",
"@storybook/react-vite": "10.0.4",
"@playwright/test": "1.57.0",
"@storybook/addon-docs": "10.1.11",
"@storybook/addon-themes": "10.1.11",
"@storybook/addon-vitest": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@tokens-studio/sd-transforms": "1.2.11",
"@types/node": "^22.15.21",
"@vitest/browser": "3.2.4",
"@vitest/coverage-v8": "3.2.4",
"@types/node": "^22.19.3",
"@vitest/browser": "4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "4.0.16",
"@zip.js/zip.js": "patch:@zip.js/zip.js@npm%3A2.7.60#~/.yarn/patches/@zip.js-zip.js-npm-2.7.60-b6b814410b.patch",
"autoprefixer": "^10.4.21",
"compression": "^1.8.1",
@@ -80,7 +81,7 @@
"gettext-parser": "^8.0.0",
"highlight.js": "^11.10.0",
"js-beautify": "^1.15.4",
"jsdom": "^27.0.0",
"jsdom": "^27.4.0",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"map-stream": "0.0.7",
@@ -109,15 +110,16 @@
"sass-embedded": "^1.89.0",
"sax": "^1.4.1",
"source-map-support": "^0.5.21",
"storybook": "10.0.4",
"storybook": "10.1.11",
"style-dictionary": "5.0.0-rc.1",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tinycolor2": "^1.6.0",
"typescript": "^5.9.2",
"ua-parser-js": "2.0.5",
"vite": "^6.3.5",
"vitest": "^3.2.0",
"vite": "^7.3.0",
"vitest": "^4.0.16",
"wait-on": "^9.0.3",
"wasm-pack": "^0.13.1",
"watcher": "^2.3.1",
"workerpool": "^9.3.2",

View File

@@ -7,7 +7,4 @@ yarn install;
yarn run playwright install chromium --with-deps;
yarn run build:storybook
exec npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
yarn run test:storybook

View File

@@ -121,24 +121,22 @@
:storybook
{:target :esm
:output-dir "target/storybook/"
:devtools {:enabled false}
:devtools {:enabled false
:console-support false}
:js-options
{:js-provider :import
:entry-keys ["module" "browser" "main"]
:export-conditions ["module" "import", "browser" "require" "default"]}
:modules
{:base
{:entries []}
:components
{:components
{:exports {default app.main.ui.ds/default
helpers app.main.ui.ds.helpers/default}
:prepend-js ";(globalThis.goog.provide = globalThis.goog.constructNamespace_);(globalThis.goog.require = globalThis.goog.module.get);"
:depends-on #{:base}}}
:depends-on #{}}}
:compiler-options
{:output-feature-set :es2020
{:output-feature-set :es-next
:output-wrapper false
:warnings {:fn-deprecated false}}}

View File

@@ -3,6 +3,8 @@ import { defineConfig } from "vite";
import { configDefaults } from "vitest/config";
import { resolve } from "path";
import { playwright } from '@vitest/browser-playwright'
// https://vitejs.dev/config/
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -32,11 +34,15 @@ export default defineConfig({
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [
{
browser: "chromium",
provider: playwright({
launchOptions: {
slowMo: 100,
timeout: 160000,
},
actionTimeout: 5000,
}),
instances: [
{browser: "chromium"},
],
},
setupFiles: [".storybook/vitest.setup.ts"],

View File

File diff suppressed because it is too large Load Diff

3
plugins/.env.example Normal file
View File

@@ -0,0 +1,3 @@
E2E_LOGIN_EMAIL=""
E2E_LOGIN_PASSWORD=""
E2E_SCREENSHOTS= "false"

55
plugins/.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
dist
tmp
/out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
.env
.angular
**/assets/plugin.js
docs/api
apps/e2e/screenshots/*.png
vite.config.*.timestamp*
vitest.config.*.timestamp*

View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

View File

@@ -0,0 +1,5 @@
#!/bin/bash
if [ -z "$HUSKY_HOOK" ] || [ "$HUSKY_HOOK" = "pre-commit" ]; then
npm run lint:affected
fi

5
plugins/.husky/pre-push Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
if [ "$HUSKY_HOOK" = "pre-push" ]; then
npm run lint:affected
fi

7
plugins/.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
.angular
/.nx/workspace-data

3
plugins/.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -0,0 +1,25 @@
# a list of other known repositories we can talk to
uplinks:
npmjs:
url: https://registry.npmjs.org/
maxage: 60m
packages:
'**':
# give all users (including non-authenticated users) full access
# because it is a local registry
access: $all
publish: $all
unpublish: $all
# if package is not available locally, proxy requests to npm registry
proxy: npmjs
# log settings
logs:
type: stdout
format: pretty
level: warn
publish:
allow_offline: true # set offline to true to allow publish offline

8
plugins/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
]
}

3
plugins/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"prettier.singleQuote": true
}

221
plugins/CHANGELOG.md Normal file
View File

@@ -0,0 +1,221 @@
## 1.3.2 (2025-07-04)
### 🩹 Fixes
- plugins-runtime public package.json ([70fd69f](https://github.com/penpot/penpot-plugins/commit/70fd69f))
### ❤️ Thank You
- Juanfran @juanfran
## 1.3.1 (2025-07-04)
### 🚀 Features
- plugins-runtime as npm library ([41c56b1](https://github.com/penpot/penpot-plugins/commit/41c56b1))
### 🩹 Fixes
- package-lock.json ([16b29f8](https://github.com/penpot/penpot-plugins/commit/16b29f8))
### ❤️ Thank You
- Juanfran @juanfran
## 1.3.0 (2025-06-25)
### 🚀 Features
- **plugin-types:** add skipChildren to exports ([b3373ba](https://github.com/penpot/penpot-plugins/commit/b3373ba))
- **plugins-runtime:** change plugins modal z-index ([c6a4a7d](https://github.com/penpot/penpot-plugins/commit/c6a4a7d))
- **plugins-runtime:** adds max resize to the screen size ([f2fe501](https://github.com/penpot/penpot-plugins/commit/f2fe501))
- **plugins-runtime:** adds localstorage wrapper API for plugins ([0006ca9](https://github.com/penpot/penpot-plugins/commit/0006ca9))
- **plugins-runtime:** add generateFontFaces method ([30e1d02](https://github.com/penpot/penpot-plugins/commit/30e1d02))
- **poc-state-plugins:** add some methods to the example ([b95961a](https://github.com/penpot/penpot-plugins/commit/b95961a))
- **poc-state-plugins:** example using the localstorage api ([b101523](https://github.com/penpot/penpot-plugins/commit/b101523))
### 🩹 Fixes
- **plugin-colors-to-tokens:** adapt to Penpot tokens metadata format ([3a1ff00](https://github.com/penpot/penpot-plugins/commit/3a1ff00))
- **plugin-colors-to-tokens:** avoid unvalid character in names ([dd0fd1a](https://github.com/penpot/penpot-plugins/commit/dd0fd1a))
- **plugin-types:** add missing board properties ([de4a2a0](https://github.com/penpot/penpot-plugins/commit/de4a2a0))
- **plugin-types:** fix problem with type ([9759964](https://github.com/penpot/penpot-plugins/commit/9759964))
- **plugins-runtime:** add allow-same-origin to iframe ([65d5351](https://github.com/penpot/penpot-plugins/commit/65d5351))
- **plugins-runtime:** fixes null checking issue ([6b5b562](https://github.com/penpot/penpot-plugins/commit/6b5b562))
- **plugins-runtime:** fix problem with resize modal position ([45dc41d](https://github.com/penpot/penpot-plugins/commit/45dc41d))
- **plugins-styles:** migrate to fonts css api v2 ([45a9ee9](https://github.com/penpot/penpot-plugins/commit/45a9ee9))
### ❤️ Thank You
- alonso.torres
- Martynas Barzda
- Xavier Julian
## 1.2.0 (2025-02-27)
### 🚀 Features
- upgrade nx & angular & prettier ([32de075](https://github.com/penpot/penpot-plugins/commit/32de075))
- add ui.resize & ui.size api ([815181d](https://github.com/penpot/penpot-plugins/commit/815181d))
- colors to tokens export plugin ([7f8a011](https://github.com/penpot/penpot-plugins/commit/7f8a011))
- transform color & opacity to rgba ([9a3e6e0](https://github.com/penpot/penpot-plugins/commit/9a3e6e0))
- **plugin-colors-to-tokens:** only rgba when the opacity is not 1 ([e922cf9](https://github.com/penpot/penpot-plugins/commit/e922cf9))
- **plugin-types:** deprecated fields in colors ([6adcc4c](https://github.com/penpot/penpot-plugins/commit/6adcc4c))
- **plugins-runtime:** add upload svg with images ([df925b5](https://github.com/penpot/penpot-plugins/commit/df925b5))
### 🩹 Fixes
- duplicated css ([19ca648](https://github.com/penpot/penpot-plugins/commit/19ca648))
- add error styles on invalid input ([1c29c34](https://github.com/penpot/penpot-plugins/commit/1c29c34))
- remove nonexistent api ([3837f1c](https://github.com/penpot/penpot-plugins/commit/3837f1c))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- Michał Korczak
## 1.1.0 (2024-12-12)
### 🚀 Features
- updated doc links ([cb49dfb](https://github.com/penpot/penpot-plugins/commit/cb49dfb))
- **plugin-types:** add support for file history versions ([eab57d7](https://github.com/penpot/penpot-plugins/commit/eab57d7))
### 🩹 Fixes
- styles rename layers ([40e08f8](https://github.com/penpot/penpot-plugins/commit/40e08f8))
- **rename-layers:** i#8951 disable buttons when empty ([#8951](https://github.com/penpot/penpot-plugins/issues/8951))
### ❤️ Thank You
- alonso.torres
- María Valderrama @mavalroot
- Marina López @cocotime
# 1.0.0 (2024-10-25)
### 🚀 Features
- **plugins-runtime:** add close callback to load api ([aeddab7](https://github.com/penpot/penpot-plugins/commit/aeddab7))
- **runtime:** unload plugin ([b4d0463](https://github.com/penpot/penpot-plugins/commit/b4d0463))
### 🩹 Fixes
- search in icons plugin ([b4664a2](https://github.com/penpot/penpot-plugins/commit/b4664a2))
- **table-plugin:** i#8965 empty cell values when importing csv files ([#8965](https://github.com/penpot/penpot-plugins/issues/8965))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- María Valderrama @mavalroot
- Marina López @cocotime
## 0.12.0 (2024-10-04)
### 🚀 Features
- e2e tests ([1371af9](https://github.com/penpot/penpot-plugins/commit/1371af9))
- add build to CI ([a434209](https://github.com/penpot/penpot-plugins/commit/a434209))
- **api-doc:** update readme ([99ff81d](https://github.com/penpot/penpot-plugins/commit/99ff81d))
- **docs:** add examples for new permissions ([2f0f7a6](https://github.com/penpot/penpot-plugins/commit/2f0f7a6))
- **e2e:** add screenshots ENV variable ([9292bf2](https://github.com/penpot/penpot-plugins/commit/9292bf2))
- **plugin-types:** add ruler guides and new zoom methods ([c8066be](https://github.com/penpot/penpot-plugins/commit/c8066be))
- **plugin-types:** add apis for comments ([e34e56c](https://github.com/penpot/penpot-plugins/commit/e34e56c))
- **plugin-types:** update comment related methods ([50bc7ba](https://github.com/penpot/penpot-plugins/commit/50bc7ba))
- **plugin-types:** removed old method and replaced with attributes ([1866299](https://github.com/penpot/penpot-plugins/commit/1866299))
- **plugins-runtime:** plugin live reload ([bbc77e4](https://github.com/penpot/penpot-plugins/commit/bbc77e4))
- **plugins-runtime:** adds new permissions `comment:read`, `comment:write` and `allow:downloads` ([5adbee2](https://github.com/penpot/penpot-plugins/commit/5adbee2))
- **plugins-runtime:** expose some public JS APIs to the plugins code ([22dfa92](https://github.com/penpot/penpot-plugins/commit/22dfa92))
- **poc-state-plugin:** add new functions to the plugin to test comments and rulers ([6adee11](https://github.com/penpot/penpot-plugins/commit/6adee11))
- **rename-layers:** final review - undo group ([2909bcc](https://github.com/penpot/penpot-plugins/commit/2909bcc))
- **runtime:** refactor plugin state ([16595c2](https://github.com/penpot/penpot-plugins/commit/16595c2))
- **runtime:** remove deprecated method ([ccc5f78](https://github.com/penpot/penpot-plugins/commit/ccc5f78))
- **table-plugin:** enhancement save config ([07af57d](https://github.com/penpot/penpot-plugins/commit/07af57d))
### 🩹 Fixes
- **e2e:** update dump params to shape model ([ade39ee](https://github.com/penpot/penpot-plugins/commit/ade39ee))
- **plugin-types:** optional path curves ([0ea57f1](https://github.com/penpot/penpot-plugins/commit/0ea57f1))
- **plugins-runtime:** clean pending timeouts ([8870dda](https://github.com/penpot/penpot-plugins/commit/8870dda))
- **plugins-runtime:** prevent plugin execution after close ([b65492a](https://github.com/penpot/penpot-plugins/commit/b65492a))
- **plugins-styles:** import svg inline ([567b0b5](https://github.com/penpot/penpot-plugins/commit/567b0b5))
- **runtime:** ses errorTrapping interferes with penpot error handler ([8c0e36d](https://github.com/penpot/penpot-plugins/commit/8c0e36d))
- **runtime:** prevent override Penpot objects ([120e9e5](https://github.com/penpot/penpot-plugins/commit/120e9e5))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- María Valderrama @mavalroot
## 0.10.0 (2024-07-31)
### 🚀 Features
- change permissions names ([99126f8](https://github.com/penpot/penpot-plugins/commit/99126f8))
- stop offering icons in the style library ([5a219e9](https://github.com/penpot/penpot-plugins/commit/5a219e9))
- new publish script ([5114e78](https://github.com/penpot/penpot-plugins/commit/5114e78))
- init e2e test ([b0af705](https://github.com/penpot/penpot-plugins/commit/b0af705))
- **docs:** how api docs are generated ([e047977](https://github.com/penpot/penpot-plugins/commit/e047977))
- **docs:** basic css theme for typedoc ([0eac44d](https://github.com/penpot/penpot-plugins/commit/0eac44d))
- **plugin-types:** update API types ([bffa467](https://github.com/penpot/penpot-plugins/commit/bffa467))
- **plugin-types:** add pages info to the file ([b54edb3](https://github.com/penpot/penpot-plugins/commit/b54edb3))
- **plugin-types:** add parent reference to the shape ([2588778](https://github.com/penpot/penpot-plugins/commit/2588778))
- **plugin-types:** add root shape reference to the pages ([c712759](https://github.com/penpot/penpot-plugins/commit/c712759))
- **plugin-types:** add undo block operations to api ([1d3ad89](https://github.com/penpot/penpot-plugins/commit/1d3ad89))
- **plugins-runtime:** update selection ([f36fa23](https://github.com/penpot/penpot-plugins/commit/f36fa23))
- **plugins-runtime:** add new events 'contentsave' and 'shapechange', changed on/off signatures ([2b8a76b](https://github.com/penpot/penpot-plugins/commit/2b8a76b))
- **plugins-runtime:** add detach shape from component method ([ff488d4](https://github.com/penpot/penpot-plugins/commit/ff488d4))
- **plugins-runtime:** add API to access to prototypes ([a554775](https://github.com/penpot/penpot-plugins/commit/a554775))
- **plugins-runtime:** add method for pages ([9a9b33a](https://github.com/penpot/penpot-plugins/commit/9a9b33a))
- **plugins-types:** expose new attributes ([9ce45a2](https://github.com/penpot/penpot-plugins/commit/9ce45a2))
### 🩹 Fixes
- typo checkox > checkbox ([877a3f2](https://github.com/penpot/penpot-plugins/commit/877a3f2))
- avoid plugin location question ([b4c6165](https://github.com/penpot/penpot-plugins/commit/b4c6165))
- add files so no unexpected when creating new plugin ([ef5629a](https://github.com/penpot/penpot-plugins/commit/ef5629a))
- eslint migration to ESM docs ([249ea62](https://github.com/penpot/penpot-plugins/commit/249ea62))
- fix runtime version ([95afbf3](https://github.com/penpot/penpot-plugins/commit/95afbf3))
- horizontal scroll height on plugins modal ([08f989a](https://github.com/penpot/penpot-plugins/commit/08f989a))
- **contrast-plugin:** update colors when shape change ([8ce04d3](https://github.com/penpot/penpot-plugins/commit/8ce04d3))
- **docs:** add missing variant on destructive button ([9fa96e9](https://github.com/penpot/penpot-plugins/commit/9fa96e9))
- **plugin-types:** readonly PenpotShapeBase width & height ([415284f](https://github.com/penpot/penpot-plugins/commit/415284f))
- **plugins-runtime:** remove plugin event listener on close ([2138985](https://github.com/penpot/penpot-plugins/commit/2138985))
- **plugins-runtime:** fix problem with types in test ([17db173](https://github.com/penpot/penpot-plugins/commit/17db173))
- **styles:** input, button & select worksans font family ([1b9d3b2](https://github.com/penpot/penpot-plugins/commit/1b9d3b2))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- María Valderrama @mavalroot
- Marina López @cocotime
- Xaviju
## 0.9.0 (2024-07-10)
### 🚀 Features
- change permissions names ([99126f8](https://github.com/penpot/penpot-plugins/commit/99126f8))
- stop offering icons in the style library ([5a219e9](https://github.com/penpot/penpot-plugins/commit/5a219e9))
- new publish script ([5114e78](https://github.com/penpot/penpot-plugins/commit/5114e78))
- **plugin-types:** update API types ([bffa467](https://github.com/penpot/penpot-plugins/commit/bffa467))
- **plugins-runtime:** update selection ([f36fa23](https://github.com/penpot/penpot-plugins/commit/f36fa23))
- **plugins-types:** expose new attributes ([9ce45a2](https://github.com/penpot/penpot-plugins/commit/9ce45a2))
### 🩹 Fixes
- typo checkox > checkbox ([877a3f2](https://github.com/penpot/penpot-plugins/commit/877a3f2))
- avoid plugin location question ([b4c6165](https://github.com/penpot/penpot-plugins/commit/b4c6165))
- fix runtime version ([2401a77](https://github.com/penpot/penpot-plugins/commit/2401a77))
- **styles:** input, button & select worksans font family ([1b9d3b2](https://github.com/penpot/penpot-plugins/commit/1b9d3b2))
### ❤️ Thank You
- alonso.torres
- Juanfran @juanfran
- Marina López @cocotime
- Xaviju @xaviju

134
plugins/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,134 @@
# Contributing Guide
Thank you for your interest in contributing to Penpot Plugins. This is a
generic guide that details how to contribute to Penpot Plugins in a way that
is efficient for everyone. If you want a specific documentation for
different parts of the platform, please refer to `docs/` directory.
## Reporting Bugs
We are using [GitHub Issues](https://github.com/penpot/penpot-plugins/issues)
for our public bugs. We keep a close eye on this and try to make it
clear when we have an internal fix in progress. Before filing a new
task, try to make sure your problem doesn't already exist.
If you found a bug, please report it, as far as possible with:
- a detailed explanation of steps to reproduce the error
- a browser and the browser version used
- a dev tools console exception stack trace (if it is available)
If you found a bug that you consider better discuss in private (for
example: security bugs), consider first send an email to
`support@penpot.app`.
**We don't have formal bug bounty program for security reports; this
is an open source application and your contribution will be recognized
in the changelog.**
## Pull requests
If you want propose a change or bug fix with the Pull-Request system
firstly you should carefully read the **DCO** section and format your
commits accordingly.
If you intend to fix a bug it's fine to submit a pull request right
away but we still recommend to file an issue detailing what you're
fixing. This is helpful in case we don't accept that specific fix but
want to keep track of the issue.
If you want to implement or start working in a new feature, please
open a **question** / **discussion** issue for it. No pull-request
will be accepted without previous chat about the changes,
independently if it is a new feature, already planned feature or small
quick win.
If is going to be your first pull request, You can learn how from this
free video series:
https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
We will use the `easy fix` mark for tag for indicate issues that are
easy for beginners.
## Commit Guidelines
To maintain a clear and organized commit history in this repository, we adhere to the Conventional Commits specification. Conventional Commits provide a structured format for commit messages, making it easier to track changes, automate versioning, and improve readability.
Please familiarize yourself with the Conventional Commits rules by visiting the [official Conventional Commits website](https://www.conventionalcommits.org/en/v1.0.0/). This specification outlines how to structure your commit messages, including types, scopes, and descriptions.
## Code of conduct
As contributors and maintainers of this project, we pledge to respect
all people who contribute through reporting issues, posting feature
requests, updating documentation, submitting pull requests or patches,
and other activities.
We are committed to making participation in this project a
harassment-free experience for everyone, regardless of level of
experience, gender, gender identity and expression, sexual
orientation, disability, personal appearance, body size, race,
ethnicity, age, or religion.
Examples of unacceptable behavior by participants include the use of
sexual language or imagery, derogatory comments or personal attacks,
trolling, public or private harassment, insults, or other
unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct. Project
maintainers who do not follow the Code of Conduct may be removed from
the project team.
This code of conduct applies both within project spaces and in public
spaces when an individual is representing the project or its
community.
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported by opening an issue or contacting one or more of the
project maintainers.
This Code of Conduct is adapted from the Contributor Covenant, version
1.1.0, available from http://contributor-covenant.org/version/1/1/0/
## Developer's Certificate of Origin (DCO)
By submitting code you are agree and can certify the below:
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Then, all your code patches (**documentation are excluded**) should
contain a sign-off at the end of the patch/commit description body. It
can be automatically added on adding `-s` parameter to `git commit`.
This is an example of the aspect of the line:
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Please, use your real name (sorry, no pseudonyms or anonymous
contributions are allowed).

382
plugins/LICENSE Normal file
View File

@@ -0,0 +1,382 @@
# Mozilla Public License Version 2.0
1. Definitions
---
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
---
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
---
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
---
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
---
- *
- 6. Disclaimer of Warranty \*
- ------------------------- \*
- *
- Covered Software is provided under this License on an "as is" \*
- basis, without warranty of any kind, either expressed, implied, or \*
- statutory, including, without limitation, warranties that the \*
- Covered Software is free of defects, merchantable, fit for a \*
- particular purpose or non-infringing. The entire risk as to the \*
- quality and performance of the Covered Software is with You. \*
- Should any Covered Software prove defective in any respect, You \*
- (not any Contributor) assume the cost of any necessary servicing, \*
- repair, or correction. This disclaimer of warranty constitutes an \*
- essential part of this License. No use of any Covered Software is \*
- authorized under this License except under this disclaimer. \*
- *
---
---
- *
- 7. Limitation of Liability \*
- -------------------------- \*
- *
- Under no circumstances and under no legal theory, whether tort \*
- (including negligence), contract, or otherwise, shall any \*
- Contributor, or anyone who distributes Covered Software as \*
- permitted above, be liable to You for any direct, indirect, \*
- special, incidental, or consequential damages of any character \*
- including, without limitation, damages for lost profits, loss of \*
- goodwill, work stoppage, computer failure or malfunction, or any \*
- and all other commercial damages or losses, even if such party \*
- shall have been informed of the possibility of such damages. This \*
- limitation of liability shall not apply to liability for death or \*
- personal injury resulting from such party's negligence to the \*
- extent applicable law prohibits such limitation. Some \*
- jurisdictions do not allow the exclusion or limitation of \*
- incidental or consequential damages, so this exclusion and \*
- limitation may not apply to You. \*
- *
---
8. Litigation
---
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
---
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
## Exhibit A - Source Code Form License Notice
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/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
## Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

82
plugins/README.md Normal file
View File

@@ -0,0 +1,82 @@
# Penpot Plugins
## What can you find here?
We've been working in an MVP to allow users to develop their own plugins and use the existing ones.
There are 2 important folders to keep an eye on: `apps` and `libs`.
In the `libs` folder you'll find:
- plugins-runtime: here you'll find the code that initializes the plugin and sets a few listeners to know when the penpot page/file/selection changes.
It has its own [README](libs/plugins-runtime/README.md).
- plugins-styles: basic css library with penpot styles in case you need help for styling your plugins.
In the `apps` folder you'll find some examples that use the libraries mentioned above.
- contrast-plugin: to run this example check <a href="#create-a-plugin-from-scratch-or-run-the-examples-from-the-apps-folder">Create a plugin from scratch</a>
- example-styles: to run this example you should run
```
npm run start:styles-example
```
Open in your browser: `http://localhost:4202/`
## Run Penpot sample plugins
This guide will help you launch a Penpot plugin from the penpot-plugins repository. Before proceeding, ensure that you have Penpot running locally by following the [setup instructions](https://help.penpot.app/technical-guide/developer/devenv/).
In the terminal, navigate to the **penpot-plugins** repository and run `npm install` to install the required dependencies.
Then, run `npm start` to launch the plugins wrapper.
After installing the dependencies, choose a plugin to launch. You can either run one of the provided examples or create your own (see "Creating a plugin from scratch" below).
To launch a plugin, Open a new terminal tab and run the appropriate startup script for the chosen plugin.
For instance, to launch the Contrast plugin, use the following command:
```
// for the contrast plugin
npm run start:plugin:contrast
```
Finally, open in your browser the specific port. In this specific example would be `http://localhost:4302`
A table listing the available plugins and their corresponding startup commands is provided below.
## Sample plugins
| Plugin | Description | PORT | Start command | Manifest URL |
| ----------------------- | ----------------------------------------------------------- | ---- | ------------------------------------- | ------------------------------------------ |
| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | npm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json |
| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | npm run start:plugin:contrast | http://localhost:4302/assets/manifest.json |
| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | npm run start:plugin:icons | http://localhost:4303/assets/manifest.json |
| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | npm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json |
| create-palette-plugin | Creates a board with all the palette colors | 4305 | npm run start:plugin:palette | http://localhost:4305/assets/manifest.json |
| table-plugin | Create or import table | 4306 | npm run start:table-plugin | http://localhost:4306/assets/manifest.json |
| rename-layers-plugin | Rename layers in bulk | 4307 | npm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json |
| colors-to-tokens-plugin | Generate tokens JSON file | 4308 | npm run start:plugin:colors-to-tokens | http://localhost:4308/assets/manifest.json |
## Web Apps
| App | Description | PORT | Start command | URL |
| --------------- | ----------------------------------------------------------------- | ---- | -------------------------------- | ---------------------- |
| plugins-runtime | Runtime for the plugins subsystem | 4200 | npm run start:app:runtime | |
| example-styles | Showcase of some of the Penpot styles that can be used in plugins | 4201 | npm run start:app:styles-example | http://localhost:4201/ |
## Creating a plugin from scratch
If you want to create a new plugin, read the following [README](docs/create-plugin.md)
## License
```
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
```
Penpot is a Kaleidos [open source project](https://kaleidos.net/)

View File

@@ -0,0 +1,51 @@
import baseConfig from '../../eslint.config.js';
import { compat } from '../../eslint.base.config.js';
export default [
...baseConfig,
...compat
.config({
extends: [
'plugin:@nx/angular',
'plugin:@angular-eslint/template/process-inline-templates',
],
})
.map((config) => ({
...config,
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
})),
...compat
.config({ extends: ['plugin:@nx/angular-template'] })
.map((config) => ({
...config,
files: ['**/*.html'],
rules: {},
})),
{ ignores: ['**/assets/*.js'] },
{
languageOptions: {
parserOptions: {
project: './tsconfig.*?.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,79 @@
{
"name": "colors-to-tokens-plugin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/colors-to-tokens-plugin/src",
"tags": ["type:plugin"],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/colors-to-tokens-plugin",
"index": "apps/colors-to-tokens-plugin/src/index.html",
"browser": "apps/colors-to-tokens-plugin/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/colors-to-tokens-plugin/tsconfig.app.json",
"assets": [
"apps/colors-to-tokens-plugin/src/favicon.ico",
"apps/colors-to-tokens-plugin/src/assets"
],
"styles": [
"libs/plugins-styles/src/lib/styles.css",
"apps/colors-to-tokens-plugin/src/styles.css"
],
"scripts": [],
"optimization": {
"scripts": true,
"styles": true,
"fonts": false
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"dependsOn": ["buildPlugin"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "colors-to-tokens-plugin:build:production"
},
"development": {
"buildTarget": "colors-to-tokens-plugin:build:development",
"host": "0.0.0.0",
"port": 4308
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "colors-to-tokens-plugin:build"
}
}
}
}

View File

@@ -0,0 +1,87 @@
:host {
display: flex;
flex-direction: column;
gap: var(--spacing-24);
padding-top: var(--spacing-36);
}
.title {
color: var(--foreground-primary);
}
.description {
padding-bottom: var(--spacing-4);
a {
color: var(--accent-primary);
text-decoration: none;
}
}
.title,
.description {
text-wrap: pretty;
text-align: center;
}
.actions {
display: flex;
gap: var(--spacing-8);
justify-content: center;
}
.download-btn {
display: flex;
gap: var(--spacing-4);
align-items: center;
app-svg {
--svg-stroke-color: var(--background-primary);
}
}
.restart-btn {
display: flex;
gap: var(--spacing-4);
align-items: center;
app-svg {
--svg-stroke-color: var(--foreground-secondary);
}
&:hover {
app-svg {
--svg-stroke-color: var(--accent-primary);
}
}
}
/* Override default button appearance */
.download-btn[data-appearance='primary']:is(button):disabled {
color: var(--background-secondary);
background-color: var(--accent-primary-muted);
border: 2px solid var(--accent-primary-muted);
app-svg {
--svg-stroke-color: var(--background-primary);
}
}
.success {
display: flex;
background-color: var(--success-950);
border-radius: var(--spacing-8);
border: 1px solid var(--success-500);
color: var(--app-white);
gap: var(--spacing-8);
padding: var(--spacing-8);
app-svg {
--svg-stroke-color: var(--success-500);
}
}
.download-note {
padding: 0 var(--spacing-8);
text-align: center;
}

View File

@@ -0,0 +1,183 @@
import { Component, effect, inject, linkedSignal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import type {
PluginMessageEvent,
PluginUIEvent,
ThemePluginEvent,
SetColorsPluginEvent,
TokenFileExtraData,
} from '../model';
import { filter, fromEvent, map, merge, take } from 'rxjs';
import { transformToToken } from './utils/transform-to-token';
import { SvgComponent } from './components/svg.component';
@Component({
selector: 'app-root',
imports: [SvgComponent],
template: `
<h1 class="title title-m">Convert your colors assets to Design Tokens</h1>
<p class="description body-m">
A Penpot plugin to generate a JSON file with your color styles in a
<a target="_blank" href="https://tr.designtokens.org/format/"
>Design Token Standard format</a
>.
</p>
@if (result()) {
<div class="success body-s">
<app-svg name="tick" />
Colors convertered to tokens successfully!
</div>
}
<div class="actions">
@if (result()) {
<button
type="button"
data-appearance="secondary"
class="restart-btn"
(click)="restart()"
>
<app-svg name="reload" />
Restart
</button>
} @else {
<button type="button" (click)="convert()" data-appearance="primary">
Convert colors
</button>
}
<button
(click)="handleDownload()"
class="download-btn"
type="button"
data-appearance="primary"
[attr.disabled]="result() ? null : true"
>
<app-svg name="download" />
Download
</button>
</div>
<!-- @if (result()) {
<p class="body-m download-note">
Now you can modify and import it (link to help center)
</p>
} -->
`,
styleUrl: './app.component.css',
host: {
'[attr.data-theme]': 'theme()',
},
})
export class AppComponent {
route = inject(ActivatedRoute);
messages$ = fromEvent<MessageEvent<PluginMessageEvent>>(window, 'message');
initialTheme$ = this.route.queryParamMap.pipe(
map((params) => params.get('theme')),
filter((theme) => !!theme),
take(1),
);
theme = toSignal(
merge(
this.initialTheme$,
this.messages$.pipe(
filter(
(event): event is MessageEvent<ThemePluginEvent> =>
event.data.type === 'theme',
),
map((event) => {
return event.data.content;
}),
),
),
);
#result = toSignal(
this.messages$.pipe(
filter(
(event): event is MessageEvent<SetColorsPluginEvent> =>
event.data.type === 'set-colors',
),
map((event) => {
if (event.data.colors) {
try {
const tokens = transformToToken(event.data.colors);
return {
tokens,
name: event.data.fileName,
};
} catch (error) {
console.error(error);
}
}
return null;
}),
),
{
initialValue: null,
},
);
result = linkedSignal(() => this.#result());
constructor() {
effect(() => {
if (this.result()) {
this.#sendMessage({
type: 'resize',
width: 410,
height: 340,
});
} else {
this.#sendMessage({ type: 'reset' });
}
});
}
#sendMessage(message: PluginUIEvent): void {
parent.postMessage(message, '*');
}
convert(): void {
this.#sendMessage({ type: 'get-colors' });
}
restart(): void {
this.result.set(null);
}
handleDownload() {
const fileTokens = this.#result();
if (!fileTokens) return;
const extraData: TokenFileExtraData = {
$themes: [],
$metadata: {
activeThemes: [],
tokenSetOrder: [],
activeSets: [],
},
};
const tokensStructure = {
...fileTokens.tokens,
...extraData,
};
const blob = new Blob([JSON.stringify(tokensStructure)], {
type: 'text/json',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileTokens.name + '-tokens.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,6 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter([])],
};

View File

@@ -0,0 +1,14 @@
:host {
display: block;
--svg-stroke-color: transparent;
--svg-fill-color: transparent;
inline-size: var(--spacing-16);
block-size: var(--spacing-16);
}
svg {
stroke: var(--svg-stroke-color);
fill: var(--svg-fill-color);
}

View File

@@ -0,0 +1,48 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-svg',
template: `
@switch (name()) {
@case ('tick') {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="1154.667 712.01 14.666 11.333"
>
<path d="m1167.333 714.01-7.333 7.333-3.333-3.333" />
<path
stroke-linecap="round"
d="m1167.333 714.01-7.333 7.333-3.333-3.333"
/>
</svg>
}
@case ('download') {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="859 710.01 16 16"
>
<path
stroke-linecap="round"
d="M873 720.01v2.667a1.335 1.335 0 0 1-1.333 1.333h-9.334a1.335 1.335 0 0 1-1.333-1.333v-2.667m2.667-3.333L867 720.01m0 0 3.333-3.333M867 720.01v-8"
/>
</svg>
}
@case ('reload') {
<svg
viewBox="0 0 16 16"
stroke-linecap="round"
stroke-linejoin="round"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.4 8a6 6 0 1 1 1.758 4.242M2.4 8l2.1-2zm0 0L1 5.5z"></path>
</svg>
}
}
`,
styleUrl: './svg.component.css',
})
export class SvgComponent {
name = input.required<'tick' | 'download' | 'reload'>();
}

View File

@@ -0,0 +1,498 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`transform colors to tokens 1`] = `
{
"colors": {
"blue": {
"050": {
"$type": "color",
"$value": "#ebf8ff",
},
"100": {
"$type": "color",
"$value": "#bee3f8",
},
"650": {
"$type": "color",
"$value": "#2a4365",
},
"700": {
"$type": "color",
"$value": "#2c5282",
},
"900": {
"$type": "color",
"$value": "#1a365d",
},
},
"gray": {
"100": {
"$type": "color",
"$value": "#edf2f7",
},
"200": {
"$type": "color",
"$value": "#e2e8f0",
},
"400": {
"$type": "color",
"$value": "#a0aec0",
},
"600": {
"$type": "color",
"$value": "#4a5568",
},
"700": {
"$type": "color",
"$value": "#2d3748",
},
"800": {
"$type": "color",
"$value": "#1a202c",
},
},
"green": {
"050": {
"$type": "color",
"$value": "#f0fff4",
},
"100": {
"$type": "color",
"$value": "#c6f6d5",
},
"300": {
"$type": "color",
"$value": "#68d391",
},
"500": {
"$type": "color",
"$value": "#38a169",
},
"600": {
"$type": "color",
"$value": "#2f855a",
},
"700": {
"$type": "color",
"$value": "#276749",
},
"800": {
"$type": "color",
"$value": "#22543d",
},
},
"pink": {
"100": {
"$type": "color",
"$value": "#fed7e2",
},
},
"purple": {
"100": {
"$type": "color",
"$value": "#e9d8fd",
},
"300": {
"$type": "color",
"$value": "#b794f4",
},
"500": {
"$type": "color",
"$value": "#805ad5",
},
},
"red": {
"100": {
"$type": "color",
"$value": "#FED7D7",
},
"300": {
"$type": "color",
"$value": "#FC8181",
},
"500": {
"$type": "color",
"$value": "#e53e3e",
},
"600": {
"$type": "color",
"$value": "#c53030",
},
"800": {
"$type": "color",
"$value": "#822727",
},
},
"shadow": {
"dark": {
"$type": "color",
"$value": "rgba(0, 0, 0, 0.69803923)",
},
"light": {
"$type": "color",
"$value": "rgba(0, 0, 0, 0.16078432)",
},
"mid": {
"$type": "color",
"$value": "rgba(0, 0, 0, 0.6)",
},
},
"white": {
"$type": "color",
"$value": "#ffffff",
},
"yellow": {
"050": {
"$type": "color",
"$value": "#fffff0",
},
"100": {
"$type": "color",
"$value": "#fefcbf",
},
"200": {
"$type": "color",
"$value": "#faf089",
},
"700": {
"$type": "color",
"$value": "#975a16",
},
"800": {
"$type": "color",
"$value": "#744210",
},
},
},
"ui/darkmode": {
"dm": {
"background": {
"blue": {
"$type": "color",
"$value": "#1a365d",
},
"green": {
"$type": "color",
"$value": "#1c4532",
},
"yellow": {
"$type": "color",
"$value": "#5f370e",
},
},
"button": {
"blue": {
"$type": "color",
"$value": "#2b6cb0",
},
"default": {
"$type": "color",
"$value": "#4a5568",
},
"green": {
"$type": "color",
"$value": "#2f855a",
},
"yellow": {
"$type": "color",
"$value": "#975a16",
},
"yellow[DONTUSE]": {
"$type": "color",
"$value": "#fefcbf",
},
},
"card": {
"background": {
"$type": "color",
"$value": "#2d3748",
},
},
"chart": {
"accent": {
"$type": "color",
"$value": "#9f7aea",
"alt": {
"$type": "color",
"$value": "#ecc94b",
},
},
"background": {
"$type": "color",
"$value": "#4a5568",
},
"green": {
"$type": "color",
"$value": "#38a169",
},
"red": {
"$type": "color",
"$value": "#e53e3e",
},
"yellow": {
"$type": "color",
"$value": "#ecc94b",
},
},
"dashboard": {
"background": {
"$type": "color",
"$value": "#1a202c",
},
},
"footer": {
"$type": "color",
"$value": "#1a202c",
},
"icon": {
"default": {
"$type": "color",
"$value": "#e2e8f0",
},
"secondary": {
"$type": "color",
"$value": "#718096",
},
},
"input": {
"$type": "color",
"$value": "#4a5568",
},
"label": {
"$type": "color",
"$value": "#a0aec0",
},
"sidebar": {
"$type": "color",
"$value": "#171923",
},
"text": {
"blue": {
"$type": "color",
"$value": "#63b3ed",
},
"default": {
"$type": "color",
"$value": "#a0aec0",
},
"emphasis": {
"$type": "color",
"$value": "#edf2f7",
},
"green": {
"default": {
"$type": "color",
"$value": "#68d391",
},
"emphasis": {
"$type": "color",
"$value": "#9ae6b4",
},
},
"red": {
"default": {
"$type": "color",
"$value": "#f56565",
},
"emphasis": {
"$type": "color",
"$value": "#FC8181",
},
},
"yellow": {
"$type": "color",
"$value": "#faf089",
},
},
},
},
"ui/lightmode": {
"lm": {
"axis": {
"line": {
"$type": "color",
"$value": "#a0aec0",
},
},
"background": {
"blue": {
"$type": "color",
"$value": "#ebf8ff",
},
"green": {
"$type": "color",
"$value": "#f0fff4",
},
"yellow": {
"$type": "color",
"$value": "#fffff0",
},
},
"bar": {
"line": {
"$type": "color",
"$value": "rgba(160, 174, 192, 0.4)",
},
},
"button": {
"$type": "color",
"$value": "#E2E8F0",
"active": {
"$type": "color",
"$value": "#a0aec0",
},
"blue": {
"$type": "color",
"$value": "#bee3f8",
},
"green": {
"$type": "color",
"$value": "#c6f6d5",
},
"yellow": {
"$type": "color",
"$value": "#fefcbf",
},
},
"card": {
"background": {
"$type": "color",
"$value": "#ffffff",
},
},
"chart": {
"accent": {
"$type": "color",
"$value": "#b794f4",
"alt": {
"$type": "color",
"$value": "#faf089",
},
},
"background": {
"$type": "color",
"$value": "#E2E8F0",
},
"green": {
"$type": "color",
"$value": "#68d391",
},
"red": {
"$type": "color",
"$value": "#FC8181",
},
"yellow": {
"$type": "color",
"$value": "#faf089",
},
},
"dashboard": {
"background": {
"$type": "color",
"$value": "#EDF2F7",
},
},
"footer": {
"$type": "color",
"$value": "#e2e8f0",
},
"icon": {
"blue": {
"$type": "color",
"$value": "#2a4365",
},
"blue1": {
"$type": "color",
"$value": "#bee3f8",
},
"blue2": {
"$type": "color",
"$value": "#0000ff",
},
"default": {
"$type": "color",
"$value": "#A0AEC0",
},
"green": {
"$type": "color",
"$value": "#276749",
},
"red": {
"$type": "color",
"$value": "#c53030",
},
"secondary": {
"$type": "color",
"$value": "#e2e8f0",
},
"yellow": {
"$type": "color",
"$value": "#744210",
},
},
"input": {
"$type": "color",
"$value": "#EDF2F7",
},
"label": {
"$type": "color",
"$value": "#4a5568",
},
"placeholder": {
"$type": "color",
"$value": "#718096",
},
"shadow": {
"$type": "color",
"$value": "rgba(0, 0, 0, 0.2)",
},
"sidebar": {
"$type": "color",
"$value": "#1a202c",
},
"text": {
"blue": {
"$type": "color",
"$value": "#2a4365",
},
"default": {
"$type": "color",
"$value": "#2d3748",
},
"emphasis": {
"$type": "color",
"$value": "#000000",
},
"green": {
"default": {
"$type": "color",
"$value": "#22543d",
},
"emphasis": {
"$type": "color",
"$value": "#38a169",
},
},
"red": {
"default": {
"$type": "color",
"$value": "#822727",
},
"emphasis": {
"$type": "color",
"$value": "#e53e3e",
},
},
"secondary": {
"$type": "color",
"$value": "#718096",
},
"yellow": {
"$type": "color",
"$value": "#744210",
},
},
},
},
}
`;

View File

@@ -0,0 +1,654 @@
import { expect, test } from 'vitest';
import { transformToToken } from './transform-to-token';
const initColors = [
{
name: 'dm chart yellow',
color: '#ecc94b',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm text blue',
color: '#63b3ed',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'green 700',
color: '#276749',
opacity: 1,
path: 'colors',
},
{
name: 'dm text green emphasis',
color: '#9ae6b4',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm icon blue',
color: '#2a4365',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm card background',
color: '#ffffff',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'gray 600',
color: '#4a5568',
opacity: 1,
path: 'colors',
},
{
name: 'yellow 200',
color: '#faf089',
opacity: 1,
path: 'colors',
},
{
name: 'lm button',
color: '#E2E8F0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'green 300',
color: '#68d391',
opacity: 1,
path: 'colors',
},
{
name: 'dm chart red',
color: '#e53e3e',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'gray 400',
color: '#a0aec0',
opacity: 1,
path: 'colors',
},
{
name: 'lm dashboard background',
color: '#EDF2F7',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm text yellow',
color: '#744210',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'pink 100',
color: '#fed7e2',
opacity: 1,
path: 'colors',
},
{
name: 'lm text secondary',
color: '#718096',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'blue 900',
color: '#1a365d',
opacity: 1,
path: 'colors',
},
{
name: 'green 800',
color: '#22543d',
opacity: 1,
path: 'colors',
},
{
name: 'red 300',
color: '#FC8181',
opacity: 1,
path: 'colors',
},
{
name: 'lm button yellow',
color: '#fefcbf',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm text red emphasis',
color: '#e53e3e',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm chart green',
color: '#38a169',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm placeholder',
color: '#718096',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'purple 500',
color: '#805ad5',
opacity: 1,
path: 'colors',
},
{
name: 'lm chart yellow',
color: '#faf089',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'shadow light',
color: '#000000',
opacity: 0.16078432,
path: 'colors',
},
{
name: 'dm footer',
color: '#1a202c',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'red 800',
color: '#822727',
opacity: 1,
path: 'colors',
},
{
name: 'lm text blue',
color: '#2a4365',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'purple 300',
color: '#b794f4',
opacity: 1,
path: 'colors',
},
{
name: 'purple 100',
color: '#e9d8fd',
opacity: 1,
path: 'colors',
},
{
name: 'dm background blue',
color: '#1a365d',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm text red default',
color: '#f56565',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'gray 700',
color: '#2d3748',
opacity: 1,
path: 'colors',
},
{
name: 'lm button blue',
color: '#bee3f8',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm icon default',
color: '#e2e8f0',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'green 600',
color: '#2f855a',
opacity: 1,
path: 'colors',
},
{
name: 'yellow 100',
color: '#fefcbf',
opacity: 1,
path: 'colors',
},
{
name: 'blue 100',
color: '#bee3f8',
opacity: 1,
path: 'colors',
},
{
name: 'dm background yellow',
color: '#5f370e',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'blue 650',
color: '#2a4365',
opacity: 1,
path: 'colors',
},
{
name: 'dm text green default',
color: '#68d391',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm bar line',
color: '#a0aec0',
opacity: 0.4,
path: 'ui / light mode',
},
{
name: 'dm background green',
color: '#1c4532',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm button blue',
color: '#2b6cb0',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'gray 100',
color: '#edf2f7',
opacity: 1,
path: 'colors',
},
{
name: 'dm button yellow',
color: '#975a16',
opacity: 1,
path: 'ui / dark mode',
},
/* 3 different blue colors in the same path */
{
name: 'lm icon blue',
color: '#bee3f8',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm icon blue',
color: '#0000ff',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm text default',
color: '#2d3748',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm icon red',
color: '#c53030',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm text green default',
color: '#22543d',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm icon green',
color: '#276749',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'shadow dark',
color: '#000000',
opacity: 0.69803923,
path: 'colors',
},
{
name: 'lm chart background',
color: '#E2E8F0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'yellow 050',
color: '#fffff0',
opacity: 1,
path: 'colors',
},
{
name: 'green 100',
color: '#c6f6d5',
opacity: 1,
path: 'colors',
},
{
name: 'lm sidebar',
color: '#1a202c',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm label',
color: '#a0aec0',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'green 050',
color: '#f0fff4',
opacity: 1,
path: 'colors',
},
{
name: 'dm button green',
color: '#2f855a',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm label',
color: '#4a5568',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm button active',
color: '#a0aec0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm icon secondary',
color: '#718096',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm chart background',
color: '#4a5568',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm axis line',
color: '#a0aec0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm button yellow[DONTUSE]',
color: '#fefcbf',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm sidebar',
color: '#171923',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm text emphasis',
color: '#edf2f7',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm chart green',
color: '#68d391',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm background blue',
color: '#ebf8ff',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'red 100',
color: '#FED7D7',
opacity: 1,
path: 'colors',
},
{
name: 'dm text default',
color: '#a0aec0',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'red 500',
color: '#e53e3e',
opacity: 1,
path: 'colors',
},
{
name: 'yellow 800',
color: '#744210',
opacity: 1,
path: 'colors',
},
{
name: 'blue 050',
color: '#ebf8ff',
opacity: 1,
path: 'colors',
},
{
name: 'gray 800',
color: '#1a202c',
opacity: 1,
path: 'colors',
},
{
name: 'lm background yellow',
color: '#fffff0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm chart accent',
color: '#9f7aea',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm shadow',
color: '#000000',
opacity: 0.2,
path: 'ui / light mode',
},
{
name: 'dm text red emphasis',
color: '#FC8181',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm input',
color: '#4a5568',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'dm chart accent alt',
color: '#ecc94b',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm button green',
color: '#c6f6d5',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'red 600',
color: '#c53030',
opacity: 1,
path: 'colors',
},
{
name: 'lm icon secondary',
color: '#e2e8f0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm text yellow',
color: '#faf089',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'green 500',
color: '#38a169',
opacity: 1,
path: 'colors',
},
{
name: 'shadow mid',
color: '#000000',
opacity: 0.6,
path: 'colors',
},
{
name: 'lm text green emphasis',
color: '#38a169',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'blue 700',
color: '#2c5282',
opacity: 1,
path: 'colors',
},
{
name: 'lm text red default',
color: '#822727',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm footer',
color: '#e2e8f0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm text emphasis',
color: '#000000',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm card background',
color: '#2d3748',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm chart accent',
color: '#b794f4',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'white',
color: '#ffffff',
opacity: 1,
path: 'colors',
},
{
name: 'dm button default',
color: '#4a5568',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm input',
color: '#EDF2F7',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm chart accent alt',
color: '#faf089',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'gray 200',
color: '#e2e8f0',
opacity: 1,
path: 'colors',
},
{
name: 'lm icon yellow',
color: '#744210',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'dm dashboard background',
color: '#1a202c',
opacity: 1,
path: 'ui / dark mode',
},
{
name: 'lm icon default',
color: '#A0AEC0',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'yellow 700',
color: '#975a16',
opacity: 1,
path: 'colors',
},
{
name: 'lm background green',
color: '#f0fff4',
opacity: 1,
path: 'ui / light mode',
},
{
name: 'lm chart red',
color: '#FC8181',
opacity: 1,
path: 'ui / light mode',
},
];
test('transform colors to tokens', () => {
const result = transformToToken(initColors);
expect(result).toMatchSnapshot();
});

View File

@@ -0,0 +1,72 @@
import { LibraryColor } from '@penpot/plugin-types';
import { TokenStructure } from '../../model';
function transformToRgba({
color,
opacity,
}: Required<Pick<LibraryColor, 'color' | 'opacity'>>) {
color = color.slice(1);
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
interface Color extends LibraryColor {
color: string;
}
export function transformToToken(colors: LibraryColor[]) {
const result: TokenStructure = {};
colors
.filter((data): data is Color => !!data.color)
.forEach((data) => {
const currentOpacity = data.opacity ?? 1;
const value =
currentOpacity === 1
? data.color
: transformToRgba({
opacity: currentOpacity,
color: data.color,
});
const names: string[] = data.name.replace(/[#{}$]/g, '').split(' ');
const key: string =
data.path.replace(' \\/ ', '/').replace(/ /g, '') || 'global';
if (!result[key]) {
result[key] = {};
}
const props = [key, ...names];
let acc = result;
props.forEach((prop, index) => {
if (!acc[prop]) {
acc[prop] = {};
}
if (index === props.length - 1) {
let propIndex = 1;
const initialProp = prop;
while (acc[prop]?.$value) {
prop = `${initialProp}${propIndex}`;
propIndex++;
}
acc[prop] = {
$value: value,
$type: 'color',
};
}
acc = acc[prop] as TokenStructure;
});
});
return result;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,7 @@
{
"name": "Colors to Tokens",
"description": "Generate a design tokens file from a list of colors",
"code": "/assets/plugin.js",
"icon": "/assets/icon.png",
"permissions": ["content:read", "library:read", "allow:downloads"]
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>colors-to-tokens-plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View File

@@ -0,0 +1,53 @@
import { LibraryColor } from '@penpot/plugin-types';
export interface Token {
$value: string;
$type: string;
}
export interface TokenFileExtraData {
$themes: [];
$metadata: TokenFileMetada;
}
export interface TokenFileMetada {
activeThemes: [];
tokenSetOrder: [];
activeSets: [];
}
export type TokenStructure = {
[key: string]: Token | TokenStructure;
};
export interface GETColorsPluginUIEvent {
type: 'get-colors';
}
export interface ResetPluginUIEvent {
type: 'reset';
}
export interface ResizePluginUIEvent {
type: 'resize';
height: number;
width: number;
}
export type PluginUIEvent =
| GETColorsPluginUIEvent
| ResizePluginUIEvent
| ResetPluginUIEvent;
export interface ThemePluginEvent {
type: 'theme';
content: string;
}
export interface SetColorsPluginEvent {
type: 'set-colors';
colors: LibraryColor[] | null;
fileName: string;
}
export type PluginMessageEvent = ThemePluginEvent | SetColorsPluginEvent;

View File

@@ -0,0 +1,50 @@
import type { PluginMessageEvent, PluginUIEvent } from './model.js';
const defaultSize = {
width: 410,
height: 280,
};
penpot.ui.open('COLORS TO TOKENS', `?theme=${penpot.theme}`, {
width: defaultSize.width,
height: defaultSize.height,
});
penpot.on('themechange', (theme) => {
sendMessage({ type: 'theme', content: theme });
});
penpot.ui.onMessage<PluginUIEvent>((message) => {
if (message.type === 'get-colors') {
const colors = penpot.library.local.colors.filter(
(color) => !color.gradient,
);
const fileName = penpot.currentFile?.name ?? 'Untitled';
sendMessage({
type: 'set-colors',
colors,
fileName,
});
} else if (message.type === 'resize') {
if (
penpot.ui.size?.width === defaultSize.width &&
penpot.ui.size?.height === defaultSize.height
) {
resize(message.width, message.height);
}
} else if (message.type === 'reset') {
resize(defaultSize.width, defaultSize.height);
}
});
function resize(width: number, height: number) {
if ('resize' in penpot.ui) {
(penpot as any).ui.resize(width, height);
}
}
function sendMessage(message: PluginMessageEvent) {
penpot.ui.sendMessage(message);
}

View File

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"types": []
}
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.editor.json"
},
{
"path": "./tsconfig.plugin.json"
}
],
"extends": "../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"files": ["src/plugin.ts"],
"include": ["../../libs/plugin-types/index.d.ts"]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,21 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
cacheDir: '../node_modules/.vite/colors-to-tokens-plugin',
test: {
watch: false,
globals: true,
cache: {
dir: '../node_modules/.vitest',
},
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../coverage/colors-to-tokens-plugin',
provider: 'v8',
},
},
});

View File

@@ -0,0 +1,51 @@
import baseConfig from '../../eslint.config.js';
import { compat } from '../../eslint.base.config.js';
export default [
...baseConfig,
...compat
.config({
extends: [
'plugin:@nx/angular',
'plugin:@angular-eslint/template/process-inline-templates',
],
})
.map((config) => ({
...config,
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
})),
...compat
.config({ extends: ['plugin:@nx/angular-template'] })
.map((config) => ({
...config,
files: ['**/*.html'],
rules: {},
})),
{ ignores: ['**/assets/*.js'] },
{
languageOptions: {
parserOptions: {
project: './tsconfig.*?.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,79 @@
{
"name": "contrast-plugin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/contrast-plugin/src",
"tags": ["type:plugin"],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/contrast-plugin",
"index": "apps/contrast-plugin/src/index.html",
"browser": "apps/contrast-plugin/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/contrast-plugin/tsconfig.app.json",
"assets": [
"apps/contrast-plugin/src/favicon.ico",
"apps/contrast-plugin/src/assets"
],
"styles": [
"libs/plugins-styles/src/lib/styles.css",
"apps/contrast-plugin/src/styles.css"
],
"scripts": [],
"optimization": {
"scripts": true,
"styles": true,
"fonts": false
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"dependsOn": ["buildPlugin"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "contrast-plugin:build:production"
},
"development": {
"buildTarget": "contrast-plugin:build:development",
"host": "0.0.0.0",
"port": 4302
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "contrast-plugin:build"
}
}
}
}

View File

@@ -0,0 +1,97 @@
.wrapper {
padding-block-start: var(--spacing-24);
}
.bold {
font-weight: 600;
}
.contrast-preview {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-block-end: var(--spacing-20);
border-block-end: 2px solid var(--background-quaternary);
}
.color-box {
block-size: 66px;
border: 1px solid var(--db-quaternary);
border-radius: var(--spacing-8);
background: linear-gradient(
to right,
var(--color1) 0%,
var(--color1) 50%,
var(--color2) 50%,
var(--color2) 100%
);
}
.select-colors {
display: flex;
justify-content: space-between;
}
.contrast-ratio {
padding-block: var(--spacing-24);
span {
color: var(--foreground-primary);
font-weight: 600;
}
}
.contrast-results {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.contrast-result {
.title {
margin-block-end: var(--spacing-4);
}
.list {
display: flex;
gap: var(--spacing-8);
}
}
.tag {
display: flex;
align-items: center;
justify-content: center;
inline-size: 42px;
block-size: 32px;
color: var(--app-white);
border: 1px solid transparent;
border-radius: var(--spacing-8);
&.good {
background-color: var(--success-950);
border-color: var(--success-500);
}
&.fail {
background-color: var(--error-950);
border-color: var(--error-700);
}
}
:host[data-theme='light'] {
.tag {
color: var(--app-black);
&.good {
background-color: #a7e8d9;
}
&.fail {
background-color: var(--error-200);
border-color: var(--error-500);
}
}
.color-box {
border: 1px solid #d0d3d6;
}
}

View File

@@ -0,0 +1,236 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import type {
PluginMessageEvent,
PluginUIEvent,
ThemePluginEvent,
} from '../model';
import { filter, fromEvent, map, merge, take } from 'rxjs';
import { CommonModule } from '@angular/common';
import { Shape } from '@penpot/plugin-types';
@Component({
imports: [CommonModule],
selector: 'app-root',
template: `
<div class="wrapper body-s">
@if (selection().length === 0) {
<p class="empty-preview">
Select two filled shapes to calculate the color contrast between them.
</p>
} @else if (selection().length === 1) {
<p class="empty-preview">
Select <span class="bold">one more</span> filled shape to calculate
the color contrast between the selected colors.
</p>
} @else if (selection().length >= 2) {
<div class="contrast-preview">
<p>Selected colors:</p>
<div class="color-box"></div>
<ul class="select-colors">
<li>
{{ color1() }}
</li>
<li>{{ color2() }}</li>
</ul>
</div>
<p class="contrast-ratio">
Contrast ratio: <span>{{ result() }} : 1</span>
</p>
<div class="contrast-results">
<div class="contrast-result">
<p class="title">Normal text:</p>
<ul class="list">
<li
class="tag"
[ngClass]="
result() >= contrastStandards.AA.normal ? 'good' : 'fail'
"
>
AA
</li>
<li
class="tag"
[ngClass]="
result() >= contrastStandards.AAA.normal ? 'good' : 'fail'
"
>
AAA
</li>
</ul>
</div>
<div class="contrast-result">
<p class="title">
Large text
<span class="body-xs">(starting from 19px bold or 24px):</span>
</p>
<ul class="list">
<li
class="tag"
[ngClass]="
result() >= contrastStandards.AA.large ? 'good' : 'fail'
"
>
AA
</li>
<li
class="tag"
[ngClass]="
result() >= contrastStandards.AAA.large ? 'good' : 'fail'
"
>
AAA
</li>
</ul>
</div>
<div class="contrast-result">
<p class="title">
Graphics
<span class="body-xs">(such as form input borders):</span>
</p>
<ul class="list">
<li
class="tag"
[ngClass]="
result() >= contrastStandards.graphics ? 'good' : 'fail'
"
>
AA
</li>
</ul>
</div>
</div>
}
</div>
`,
styleUrl: './app.component.css',
host: {
'[attr.data-theme]': 'theme()',
'[style.--color1]': 'color1()',
'[style.--color2]': 'color2()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
#route = inject(ActivatedRoute);
#messages$ = fromEvent<MessageEvent<PluginMessageEvent>>(window, 'message');
#initialTheme$ = this.#route.queryParamMap.pipe(
map((params) => params.get('theme')),
filter((theme) => !!theme),
take(1),
);
selection = toSignal(
this.#messages$.pipe(
filter(
(event) =>
event.data.type === 'init' || event.data.type === 'selection',
),
map((event) => {
if (event.data.type === 'init') {
return event.data.content.selection;
} else if (event.data.type === 'selection') {
return event.data.content;
}
return [];
}),
map((shapes) => {
return shapes
.map((shape) => this.#getShapeColor(shape))
.filter((color): color is string => !!color);
}),
),
{
initialValue: [],
},
);
theme = toSignal(
merge(
this.#initialTheme$,
this.#messages$.pipe(
map((event) => event.data),
filter((data): data is ThemePluginEvent => data.type === 'theme'),
map((data) => {
return data.content;
}),
),
),
);
color1 = computed(() => {
return this.selection().at(-2);
});
color2 = computed(() => {
return this.selection().at(-1);
});
result = computed<number>(() => {
const color1 = this.color1();
const color2 = this.color2();
if (!color1 || !color2) {
return 0;
}
const lum1 = this.#getLuminosity(color1) + 0.05;
const lum2 = this.#getLuminosity(color2) + 0.05;
const result = lum1 > lum2 ? lum1 / lum2 : lum2 / lum1;
return Number(result.toFixed(2));
});
contrastStandards = {
AA: {
normal: 4.5,
large: 3,
},
AAA: {
normal: 7,
large: 4.5,
},
graphics: 3,
} as const;
constructor() {
this.#sendMessage({ type: 'ready' });
}
#getLuminosity(color: string) {
const rgb = this.#hexToRgb(color);
const a = rgb.map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
}
#hexToRgb(hex: string) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
}
#getShapeColor(shape?: Shape): string | undefined {
const fills = shape?.fills;
if (fills && fills !== 'mixed') {
return fills?.[0]?.fillColor ?? shape?.strokes?.[0]?.strokeColor;
}
return undefined;
}
#sendMessage(message: PluginUIEvent) {
parent.postMessage(message, '*');
}
}

View File

@@ -0,0 +1,6 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter([])],
};

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,7 @@
{
"name": "Contrast",
"description": "Measure contrast plugin",
"code": "/assets/plugin.js",
"icon": "/assets/icon.png",
"permissions": ["content:read"]
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>contrast-plugin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View File

@@ -0,0 +1,29 @@
import { Shape } from '@penpot/plugin-types';
export interface InitPluginUIEvent {
type: 'ready';
}
export type PluginUIEvent = InitPluginUIEvent;
export interface InitPluginEvent {
type: 'init';
content: {
theme: string;
selection: Shape[];
};
}
export interface SelectionPluginEvent {
type: 'selection';
content: Shape[];
}
export interface ThemePluginEvent {
type: 'theme';
content: string;
}
export type PluginMessageEvent =
| InitPluginEvent
| SelectionPluginEvent
| ThemePluginEvent;

View File

@@ -0,0 +1,55 @@
import type { PluginMessageEvent, PluginUIEvent } from './model.js';
penpot.ui.open('CONTRAST PLUGIN', `?theme=${penpot.theme}`, {
width: 285,
height: 525,
});
penpot.ui.onMessage<PluginUIEvent>((message) => {
if (message.type === 'ready') {
sendMessage({
type: 'init',
content: {
theme: penpot.theme,
selection: penpot.selection,
},
});
initEvents();
}
});
penpot.on('selectionchange', () => {
const shapes = penpot.selection;
sendMessage({ type: 'selection', content: shapes });
initEvents();
});
let listeners: symbol[] = [];
function initEvents() {
listeners.forEach((listener) => {
penpot.off(listener);
});
listeners = penpot.selection.map((shape) => {
return penpot.on(
'shapechange',
() => {
const shapes = penpot.selection;
sendMessage({ type: 'selection', content: shapes });
},
{ shapeId: shape.id },
);
});
}
penpot.on('themechange', () => {
const theme = penpot.theme;
sendMessage({ type: 'theme', content: theme });
});
function sendMessage(message: PluginMessageEvent) {
penpot.ui.sendMessage(message);
}

View File

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"types": []
}
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.editor.json"
},
{
"path": "./tsconfig.plugin.json"
}
],
"extends": "../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"files": ["src/plugin.ts"],
"include": ["../../libs/plugin-types/index.d.ts"]
}

View File

@@ -0,0 +1,20 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
cacheDir: '../node_modules/.vite/contrast-plugin',
test: {
globals: true,
cache: {
dir: '../node_modules/.vitest',
},
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../coverage/contrast-plugin',
provider: 'v8',
},
},
});

View File

@@ -0,0 +1,3 @@
{
"presets": ["@nx/js/babel"]
}

View File

@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "es2016"
}
}

View File

@@ -0,0 +1,26 @@
import baseConfig from '../../eslint.config.js';
export default [
...baseConfig,
{
languageOptions: {
parserOptions: {
project: './tsconfig.*?.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {},
},
{
files: ['**/*.js', '**/*.jsx'],
rules: {},
},
{ ignores: ['vite.config.ts'] },
];

View File

@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"></html>

View File

@@ -0,0 +1,8 @@
{
"name": "create-palette-plugin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/create-palette-plugin/src",
"tags": ["type:plugin"],
"targets": {}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,7 @@
{
"name": "Create Palette from library",
"description": "Create a board with all the colors in the local library",
"code": "/plugin.js",
"icon": "/assets/icon.png",
"permissions": ["content:read", "content:write", "library:read"]
}

View File

@@ -0,0 +1,104 @@
main();
function main() {
createPalette();
penpot.closePlugin();
}
function createPalette() {
const colors = penpot.library.local.colors.sort((a, b) =>
a.name.toLowerCase() > b.name.toLowerCase()
? 1
: a.name.toLowerCase() < b.name.toLowerCase()
? -1
: 0,
);
const cols = 4;
const rows = Math.ceil(colors.length / cols);
const width = cols * 200 + Math.max(0, cols - 1) * 10 + 20;
const height = rows * 100 + Math.max(0, rows - 1) * 10 + 20;
const board = penpot.createBoard();
board.name = 'Palette';
const viewport = penpot.viewport;
board.x = viewport.center.x - width / 2;
board.y = viewport.center.y - height / 2;
if (colors.length === 0) {
// NO colors return
return;
}
board.resize(width, height);
board.borderRadius = 8;
// create grid
const grid = board.addGridLayout();
for (let i = 0; i < rows; i++) {
grid.addRow('flex', 1);
}
for (let i = 0; i < cols; i++) {
grid.addColumn('flex', 1);
}
grid.alignItems = 'center';
grid.justifyItems = 'start';
grid.justifyContent = 'stretch';
grid.alignContent = 'stretch';
grid.rowGap = 10;
grid.columnGap = 10;
grid.verticalPadding = 10;
grid.horizontalPadding = 10;
grid.horizontalSizing = 'auto';
// create text
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const i = row * cols + col;
const color = colors[i];
if (i >= colors.length) {
return;
}
const board = penpot.createBoard();
grid.appendChild(board, row + 1, col + 1);
board.fills = [color.asFill()];
board.strokes = [
{ strokeColor: '#000000', strokeOpacity: 0.3, strokeStyle: 'solid' },
];
if (board.layoutChild) {
board.layoutChild.horizontalSizing = 'fill';
board.layoutChild.verticalSizing = 'fill';
}
const flex = board.addFlexLayout();
flex.alignItems = 'center';
flex.justifyContent = 'center';
flex.verticalPadding = 8;
flex.horizontalPadding = 8;
const text = penpot.createText(color.name);
text.fontWeight = 'bold';
text.fontVariantId = 'bold';
text.growType = 'auto-width';
text.strokes = [
{
strokeColor: '#FFFFFF',
strokeWidth: 1,
strokeAlignment: 'outer',
strokeOpacity: 0.5,
strokeStyle: 'solid',
},
];
board.appendChild(text);
}
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts", "../../libs/plugin-types/index.d.ts"]
}

View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,58 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/create-palette-plugin',
server: {
port: 4305,
host: '0.0.0.0',
},
preview: {
port: 4305,
host: '0.0.0.0',
},
plugins: [nxViteTsPaths()],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
build: {
outDir: '../../dist/apps/create-palette-plugin',
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
rollupOptions: {
input: {
plugin: 'src/plugin.ts',
index: './index.html',
},
output: {
entryFileNames: '[name].js',
},
},
},
test: {
globals: true,
cache: {
dir: '../../node_modules/.vitest',
},
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/create-palette-plugin',
provider: 'v8',
},
},
});

View File

@@ -0,0 +1,32 @@
import baseConfig from '../../eslint.config.js';
import typescriptEslintParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
...baseConfig,
{
languageOptions: {
parser: typescriptEslintParser,
parserOptions: { project: './apps/e2e/tsconfig.json' },
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {},
},
{
files: ['**/*.js', '**/*.jsx'],
rules: {},
},
{ ignores: ['vite.config.ts'] },
];

View File

@@ -0,0 +1,8 @@
{
"name": "e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"implicitDependencies": [],
"tags": ["type:e2e"],
"targets": {}
}

View File

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
export interface FileRpc {
'~:name': string;
'~:revn': number;
'~:id': string;
'~:is-shared': boolean;
'~:version': number;
'~:project-id': string;
'~:data': {
'~:pages': string[];
'~:objects': string[];
'~:styles': string[];
'~:components': string[];
'~:styles-v2': string[];
'~:components-v2': string[];
'~:features': string[];
};
}

View File

@@ -0,0 +1,7 @@
export interface Shape {
id: string;
frameId?: string;
parentId?: string;
shapes?: string[];
layoutGridCells?: Shape[];
}

View File

@@ -0,0 +1,92 @@
import componentLibrary from './plugins/component-library';
import testingPlugin from './plugins/create-board-text-rect';
import flex from './plugins/create-flexlayout';
import grid from './plugins/create-gridlayout';
import rulerGuides from './plugins/create-ruler-guides';
import createText from './plugins/create-text';
import group from './plugins/group';
import insertSvg from './plugins/insert-svg';
import pluginData from './plugins/plugin-data';
import comments from './plugins/create-comments';
import { Agent } from './utils/agent';
describe('Plugins', () => {
it('create board - text - rectable', async () => {
const agent = await Agent();
const result = await agent.runCode(testingPlugin.toString(), {
screenshot: 'create-board-text-rect',
});
expect(result).toMatchSnapshot();
});
it('create flex layout', async () => {
const agent = await Agent();
const result = await agent.runCode(flex.toString(), {
screenshot: 'create-flexlayout',
});
expect(result).toMatchSnapshot();
});
it('create grid layout', async () => {
const agent = await Agent();
const result = await agent.runCode(grid.toString(), {
screenshot: 'create-gridlayout',
});
expect(result).toMatchSnapshot();
});
it('group and ungroup', async () => {
const agent = await Agent();
const result = await agent.runCode(group.toString(), {
screenshot: 'group-ungroup',
});
expect(result).toMatchSnapshot();
});
it('insert svg', async () => {
const agent = await Agent();
const result = await agent.runCode(insertSvg.toString(), {
screenshot: 'insert-svg',
});
expect(result).toMatchSnapshot();
});
it('plugin data', async () => {
const agent = await Agent();
const result = await agent.runCode(pluginData.toString());
expect(result).toMatchSnapshot();
});
it('component library', async () => {
const agent = await Agent();
const result = await agent.runCode(componentLibrary.toString(), {
screenshot: 'component-library',
});
expect(result).toMatchSnapshot();
});
it('text and textrange', async () => {
const agent = await Agent();
const result = await agent.runCode(createText.toString(), {
screenshot: 'create-text',
});
expect(result).toMatchSnapshot();
});
it('ruler guides', async () => {
const agent = await Agent();
const result = await agent.runCode(rulerGuides.toString(), {
screenshot: 'create-ruler-guides',
});
expect(result).toMatchSnapshot();
});
it('comments', async () => {
const agent = await Agent();
const result = await agent.runCode(comments.toString(), {
screenshot: 'create-comments',
avoidSavedStatus: true,
});
expect(result).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,10 @@
export default function () {
const rectangle = penpot.createRectangle();
rectangle.x = penpot.viewport.center.x;
rectangle.y = penpot.viewport.center.y;
const shape = penpot.currentPage?.getShapeById(rectangle.id);
if (shape) {
penpot.library.local.createComponent([shape]);
}
}

View File

@@ -0,0 +1,58 @@
import type { Board, Rectangle, Text } from '@penpot/plugin-types';
export default function () {
function createText(text: string): Text | undefined {
const textNode = penpot.createText(text);
if (!textNode) {
return;
}
textNode.x = penpot.viewport.center.x;
textNode.y = penpot.viewport.center.y;
return textNode;
}
function createRectangle(): Rectangle {
const rectangle = penpot.createRectangle();
rectangle.setPluginData('customKey', 'customValue');
rectangle.x = penpot.viewport.center.x;
rectangle.y = penpot.viewport.center.y;
rectangle.resize(200, 200);
return rectangle;
}
function createBoard(): Board {
const board = penpot.createBoard();
board.name = 'Board name';
board.x = penpot.viewport.center.x;
board.y = penpot.viewport.center.y;
board.borderRadius = 8;
board.resize(300, 300);
const text = penpot.createText('Hello from board');
if (!text) {
throw new Error('Could not create text');
}
text.x = 10;
text.y = 10;
board.appendChild(text);
return board;
}
createBoard();
createRectangle();
createText('Hello from plugin');
}

View File

@@ -0,0 +1,40 @@
export default function () {
async function createComment() {
const page = penpot.currentPage;
if (page) {
await page.addCommentThread('Hello world!', {
x: penpot.viewport.center.x,
y: penpot.viewport.center.y,
});
}
}
async function replyComment() {
const page = penpot.currentPage;
if (page) {
const comments = await page.findCommentThreads({
onlyYours: true,
showResolved: false,
});
await comments[0].reply('This is a reply.');
}
}
async function deleteComment() {
const page = penpot.currentPage;
if (page) {
const commentThreads = await page.findCommentThreads({
onlyYours: true,
showResolved: false,
});
await page.removeCommentThread(commentThreads[0]);
}
}
createComment();
replyComment();
deleteComment();
}

View File

@@ -0,0 +1,26 @@
export default function () {
function createFlexLayout(): void {
const board = penpot.createBoard();
board.horizontalSizing = 'auto';
board.verticalSizing = 'auto';
board.x = penpot.viewport.center.x;
board.y = penpot.viewport.center.y;
const flex = board.addFlexLayout();
flex.dir = 'column';
flex.wrap = 'wrap';
flex.alignItems = 'center';
flex.justifyContent = 'center';
flex.verticalPadding = 5;
flex.horizontalPadding = 5;
flex.horizontalSizing = 'fill';
flex.verticalSizing = 'fill';
board.appendChild(penpot.createRectangle());
board.appendChild(penpot.createEllipse());
}
createFlexLayout();
}

View File

@@ -0,0 +1,27 @@
export default function () {
function createGridLayout(): void {
const board = penpot.createBoard();
board.x = penpot.viewport.center.x;
board.y = penpot.viewport.center.y;
const grid = board.addGridLayout();
grid.addRow('flex', 1);
grid.addRow('flex', 1);
grid.addColumn('flex', 1);
grid.addColumn('flex', 1);
grid.alignItems = 'center';
grid.justifyItems = 'start';
grid.justifyContent = 'space-between';
grid.alignContent = 'stretch';
grid.rowGap = 10;
grid.columnGap = 10;
grid.verticalPadding = 5;
grid.horizontalPadding = 5;
grid.horizontalSizing = 'auto';
grid.verticalSizing = 'auto';
}
createGridLayout();
}

View File

@@ -0,0 +1,21 @@
export default function () {
function createRulerGuides(): void {
const page = penpot.currentPage;
if (page) {
page.addRulerGuide('horizontal', penpot.viewport.center.x);
page.addRulerGuide('vertical', penpot.viewport.center.y);
}
}
function removeRulerGuides(): void {
const page = penpot.currentPage;
if (page) {
page.removeRulerGuide(page.rulerGuides[0]);
}
}
createRulerGuides();
removeRulerGuides();
}

View File

@@ -0,0 +1,23 @@
export default function () {
function createText(): void {
const text = penpot.createText('Hello World!');
if (text) {
text.x = penpot.viewport.center.x;
text.y = penpot.viewport.center.y;
text.growType = 'auto-width';
text.textTransform = 'uppercase';
text.textDecoration = 'underline';
text.fontId = 'gfont-work-sans';
text.fontStyle = 'italic';
text.fontSize = '20';
text.fontWeight = '500';
const textRange = text.getRange(0, 5);
textRange.fontSize = '40';
textRange.fills = [{ fillColor: '#ff6fe0', fillOpacity: 1 }];
}
}
createText();
}

View File

@@ -0,0 +1,29 @@
export default function () {
function group() {
const selected = penpot.selection;
if (selected.length && !penpot.utils.types.isGroup(selected[0])) {
return penpot.group(selected);
}
}
function ungroup() {
const selected = penpot.selection;
if (selected.length && penpot.utils.types.isGroup(selected[0])) {
return penpot.ungroup(selected[0]);
}
}
const rectangle = penpot.createRectangle();
rectangle.x = penpot.viewport.center.x;
rectangle.y = penpot.viewport.center.y;
const rectangle2 = penpot.createRectangle();
rectangle2.x = penpot.viewport.center.x + 100;
rectangle2.y = penpot.viewport.center.y + 100;
penpot.selection = [rectangle, rectangle2];
group();
ungroup();
}

View File

@@ -0,0 +1,21 @@
export default function () {
function insertSvg(svg: string) {
const icon = penpot.createShapeFromSvg(svg);
if (icon) {
icon.name = 'Test icon';
icon.x = penpot.viewport.center.x;
icon.y = penpot.viewport.center.y;
}
return icon;
}
const svg = `
<svg width="300" height="130" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="100" x="10" y="10" rx="20" ry="20" fill="blue" />
Sorry, your browser does not support inline SVG.
</svg>`;
insertSvg(svg);
}

View File

@@ -0,0 +1,6 @@
export default function () {
const rectangle = penpot.createRectangle();
rectangle?.setPluginData('testData', 'test');
return rectangle?.getPluginData('testData');
}

View File

@@ -0,0 +1,167 @@
import puppeteer from 'puppeteer';
import { PenpotApi } from './api';
import { getFileUrl } from './get-file-url';
import { idObjectToArray } from './clean-id';
import { Shape } from '../models/shape.model';
const screenshotsEnable = process.env['E2E_SCREENSHOTS'] === 'true';
function replaceIds(shapes: Shape[]) {
let id = 1;
const getId = () => {
return String(id++);
};
function replaceChildrenId(id: string, newId: string) {
for (const node of shapes) {
if (node.parentId === id) {
node.parentId = newId;
}
if (node.frameId === id) {
node.frameId = newId;
}
if (node.shapes) {
node.shapes = node.shapes?.map((shapeId) => {
return shapeId === id ? newId : shapeId;
});
}
if (node.layoutGridCells) {
node.layoutGridCells = idObjectToArray(node.layoutGridCells, newId);
}
}
}
for (const node of shapes) {
const previousId = node.id;
node.id = getId();
replaceChildrenId(previousId, node.id);
}
}
export async function Agent() {
console.log('Initializing Penpot API...');
const penpotApi = await PenpotApi();
console.log('Creating file...');
const file = await penpotApi.createFile();
console.log('File created with id:', file['~:id']);
const fileUrl = getFileUrl(file);
console.log('File URL:', fileUrl);
console.log('Launching browser...');
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
console.log('Setting authentication cookie...');
page.setCookie({
name: 'auth-token',
value: penpotApi.getAuth().split('=')[1],
domain: 'localhost',
path: '/',
expires: (Date.now() + 3600 * 1000) / 1000,
});
console.log('Navigating to file URL...');
await page.goto(fileUrl);
await page.waitForSelector('[data-testid="viewport"]');
console.log('Page loaded and viewport selector found.');
page
.on('console', async (message) => {
console.log(`${message.type()} ${message.text()}`);
})
.on('pageerror', (message) => {
console.error('Page error:', message);
});
const finish = async () => {
console.log('Deleting file and closing browser...');
await penpotApi.deleteFile(file['~:id']);
await browser.close();
console.log('Clean up done.');
};
return {
async runCode(
code: string,
options: {
screenshot?: string;
autoFinish?: boolean;
avoidSavedStatus?: boolean;
} = {
screenshot: '',
autoFinish: true,
avoidSavedStatus: false,
},
) {
const autoFinish = options.autoFinish ?? true;
console.log('Running plugin code...');
await page.evaluate((testingPlugin) => {
(globalThis as any).ɵloadPlugin({
pluginId: 'TEST',
name: 'Test',
code: `
(${testingPlugin})();
`,
icon: '',
description: '',
permissions: ['content:read', 'content:write'],
});
}, code);
if (!options.avoidSavedStatus) {
console.log('Waiting for save status...');
await page.waitForSelector(
'.main_ui_workspace_right_header__saved-status',
{
timeout: 10000,
},
);
console.log('Save status found.');
}
if (options.screenshot && screenshotsEnable) {
console.log('Taking screenshot:', options.screenshot);
await page.screenshot({
path: 'screenshots/' + options.screenshot + '.png',
});
}
return new Promise((resolve) => {
page.once('console', async (msg) => {
const args = (await Promise.all(
msg.args().map((arg) => arg.jsonValue()),
)) as Record<string, unknown>[];
const result = Object.values(args[1]) as Shape[];
replaceIds(result);
console.log('IDs replaced in result.');
resolve(result);
if (autoFinish) {
console.log('Auto finish enabled. Cleaning up...');
finish();
}
});
console.log('Evaluating debug.dump_objects...');
page.evaluate(`
debug.dump_objects();
`);
});
},
finish,
};
}

View File

@@ -0,0 +1,85 @@
import { FileRpc } from '../models/file-rpc.model';
const apiUrl = 'http://localhost:3449';
export async function PenpotApi() {
if (!process.env['E2E_LOGIN_EMAIL']) {
throw new Error('E2E_LOGIN_EMAIL not set');
}
const resultLoginRequest = await fetch(
`${apiUrl}/api/rpc/command/login-with-password`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
},
body: JSON.stringify({
'~:email': process.env['E2E_LOGIN_EMAIL'],
'~:password': process.env['E2E_LOGIN_PASSWORD'],
}),
},
);
const loginData = await resultLoginRequest.json();
const authToken = resultLoginRequest.headers
.get('set-cookie')
?.split(';')
.at(0);
if (!authToken) {
throw new Error('Login failed');
}
return {
getAuth: () => authToken,
createFile: async () => {
const createFileRequest = await fetch(
`${apiUrl}/api/rpc/command/create-file`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
cookie: authToken,
credentials: 'include',
},
body: JSON.stringify({
'~:name': `test file ${new Date().toISOString()}`,
'~:project-id': loginData['~:default-project-id'],
'~:features': {
'~#set': [
'fdata/objects-map',
'fdata/pointer-map',
'fdata/shape-data-type',
'components/v2',
'styles/v2',
'layout/grid',
'plugins/runtime',
],
},
}),
},
);
return (await createFileRequest.json()) as FileRpc;
},
deleteFile: async (fileId: string) => {
const deleteFileRequest = await fetch(
`${apiUrl}/api/rpc/command/delete-file`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
cookie: authToken,
credentials: 'include',
},
body: JSON.stringify({
'~:id': fileId,
}),
},
);
return deleteFileRequest;
},
};
}

View File

@@ -0,0 +1,14 @@
import { Shape } from '../models/shape.model';
export function cleanId(id: string) {
return id.replace('~u', '');
}
export function idObjectToArray(obj: Shape[], newId: string) {
return Object.values(obj).map((item) => {
return {
...item,
id: newId,
};
});
}

View File

@@ -0,0 +1,10 @@
import { FileRpc } from '../models/file-rpc.model';
import { cleanId } from './clean-id';
export function getFileUrl(file: FileRpc) {
const projectId = cleanId(file['~:project-id']);
const fileId = cleanId(file['~:id']);
const pageId = cleanId(file['~:data']['~:pages'][0]);
return `http://localhost:3449/#/workspace/${projectId}/${fileId}?page-id=${pageId}`;
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"src/**/*.ts",
"../../libs/plugin-types/index.d.ts"
]
}

View File

@@ -0,0 +1,23 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/e2e',
test: {
testTimeout: 20000,
watch: false,
globals: true,
cache: {
dir: '../node_modules/.vitest',
},
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../coverage/e2e',
provider: 'v8',
},
setupFiles: ['dotenv/config'],
},
});

View File

@@ -0,0 +1,3 @@
{
"presets": ["@nx/js/babel"]
}

View File

@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "es2016"
}
}

Some files were not shown because too many files have changed in this diff Show More