diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2dfe4a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) KALEIDOS INC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 482b120..946e085 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,83 @@ -# penpot-github-sync-plugin -A Penpot plugin that syncs design tokens stored in Penpot with files stored in GitHub repositories. +# Penpot Tokens Sync Plugin + +A professional, high-quality Penpot plugin designed to bridge the gap between Penpot design tokens and developer-friendly token formats (Tokens Studio/DTCG). Sync your tokens directly with GitHub repositories for a seamless Design-to-Code workflow. + +## ๐Ÿš€ Purpose + +This plugin serves as a "Source of Truth" connector. It allows designers using Penpot to: +1. **Sync** the tokens with a GitHub repository using Push and Pull operations. +2. **Review** semantic differences before committing or applying changes to ensure design consistency. + +## โœจ Features + +- **DTCG Support**: Full compatibility with the Design Tokens Community Group (DTCG) specification. +- **GitHub Integration**: Built-in support for GitHub sync using Personal Access Tokens (PAT). +- **Visual Diffing**: Review exactly what changed (Added, Modified, Removed) before applying updates. +- **Theme Management**: Support for multi-theme exports and token sets. +- **Zero Dependencies (Runtime)**: Lightweight, modular JavaScript architecture with no heavy external libraries in the plugin bundle. + +## ๐Ÿ›  Setup Instructions + +### Prerequisites +- A Penpot account and a Penpot project. +- A GitHub account and a repository to store your tokens. +- [GitHub Personal Access Token (PAT)](https://github.com/settings/tokens) with `repo` scope. + +### Installation +1. Clone this repository: + ```bash + git clone https://github.com/tokens-studio/penpot-github-sync-plugin.git + cd penpot-github-sync-plugin + ``` +2. Install dependencies: + ```bash + npm install + ``` +3. Start the development server: + ```bash + npm run dev + ``` +4. In Penpot, go to **Plugins** -> **Developer** -> **Create Plugin**. +5. Set the **URL** to `http://localhost:4400/manifest.json`. +6. Open the plugin in your Penpot file. + +## ๐Ÿ”„ GitHub Sync Functionality + +### 1. Adding a Provider +Click the **+ Add Provider** button and fill in the details: +- **Name**: A label for this configuration. +- **Token**: Your GitHub PAT. +- **Repository**: The path to your repo (e.g., `owner/repo`). +- **Branch**: The branch to sync with (e.g., `main`). +- **Path**: The file path in the repo (e.g., `tokens/design-tokens.json`). + +### 2. Pushing Tokens (Design -> Code) +- Click **โ†‘ Push** next to a provider. +- The plugin extracts your Penpot tokens and compares them with the remote file. +- Review the differences in the modal. +- Enter a commit message and confirm. + +### 3. Pulling Tokens (Code -> Design) +- Click **โ†“ Pull** next to a provider. +- The plugin fetches the remote tokens. +- Review the changes that will be applied to your Penpot file. +- Confirm to update your local sets, tokens, and themes. + +## ๐Ÿงช Development + +### Build for Production +To generate a production-ready bundle: +```bash +npm run build +``` +The output will be in the `dist/` directory. + +### Project Structure +- `src/plugin/`: Penpot API logic (runs in the Penpot environment). +- `src/ui/`: Plugin UI logic (runs in the iframe). +- `src/common/`: Shared utilities (e.g., semantic diff logic). +- `index.html`: Main UI template. +- `style.css`: Design system and component styles. + +## ๐Ÿ“„ License +MIT License. See [LICENSE](LICENSE) for details. diff --git a/index.html b/index.html new file mode 100644 index 0000000..ccb2ee3 --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + Penpot Tokens Sync + + + +
+
+

Penpot Tokens Sync

+

Sync your design tokens with Tokens Studio/DTCG format.

+
+ +
+
+

GitHub Sync

+ +
+
+ +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..462d378 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1347 @@ +{ + "name": "penpot-plugin-starter-template", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "penpot-plugin-starter-template", + "version": "0.0.0", + "dependencies": { + "@octokit/rest": "^22.0.1", + "@penpot/plugin-styles": "1.3.2", + "@penpot/plugin-types": "1.3.2" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vite-live-preview": "^0.3.2" + } + }, + "node_modules/@commander-js/extra-typings": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", + "integrity": "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==", + "dev": true, + "peerDependencies": { + "commander": "~12.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@penpot/plugin-styles": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@penpot/plugin-styles/-/plugin-styles-1.3.2.tgz", + "integrity": "sha512-/xeJpAujCoeN2oMxNsr31EcCpZ2ztmy8LIvG19GEXgwdaRXLWjh6P5FNptk7jBybOMxeI6o18Woy8oB0j+LqYQ==" + }, + "node_modules/@penpot/plugin-types": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@penpot/plugin-types/-/plugin-types-1.3.2.tgz", + "integrity": "sha512-f0kmmZaFNs9sGtSmqmSJQYCs5Qt+KYgTD8RneUjL+Dv+zfNQnd5e4L+iHSYFJ4HWvcDvTiK7F/gya7PwMTu7WA==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/ansi-html": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@types/ansi-html/-/ansi-html-0.0.0.tgz", + "integrity": "sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==", + "dev": true + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "24.0.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", + "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", + "dev": true, + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-html": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz", + "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==" + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/p-defer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz", + "integrity": "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" + }, + "node_modules/vite": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", + "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-live-preview": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/vite-live-preview/-/vite-live-preview-0.3.2.tgz", + "integrity": "sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@commander-js/extra-typings": "^12.1.0", + "@types/ansi-html": "^0.0.0", + "@types/debug": "^4.1.12", + "@types/ws": "^8.5.10", + "ansi-html": "^0.0.9", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "debug": "^4.3.5", + "escape-goat": "^4.0.0", + "p-defer": "^4.0.1", + "ws": "^8.17.0" + }, + "bin": { + "vite-live-preview": "bin.js", + "vlp": "bin.js" + }, + "peerDependencies": { + "vite": ">=5.2.13" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0778fbe --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "penpot-plugin-starter-template", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "vite build" + }, + "dependencies": { + "@octokit/rest": "^22.0.1", + "@penpot/plugin-styles": "1.3.2", + "@penpot/plugin-types": "1.3.2" + }, + "devDependencies": { + "vite": "^7.0.5", + "vite-live-preview": "^0.3.2" + } +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..e278cc0 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Penpot Tokens Exporter", + "code": "plugin.js", + "description": "Export all design tokens from your Penpot file as JSON", + "permissions": [ + "content:read", + "content:write", + "library:read" + ] +} \ No newline at end of file diff --git a/src/common/diff.js b/src/common/diff.js new file mode 100644 index 0000000..cc08d1f --- /dev/null +++ b/src/common/diff.js @@ -0,0 +1,154 @@ +/** + * Computes a semantic diff between two token data objects (DTCG format). + * @param {Object} oldData + * @param {Object} newData + * @returns {Object} A record of differences by set/theme. + */ +export function computeSemanticDiff(oldData, newData) { + const result = {}; + + // Find all keys from both objects + const allKeys = new Set([ + ...Object.keys(oldData || {}), + ...Object.keys(newData || {}), + ]); + + allKeys.forEach((key) => { + // Skip internal metadata + if (key === "$metadata") return; + + const oldVal = oldData ? oldData[key] : undefined; + const newVal = newData ? newData[key] : undefined; + + if (key === "$themes") { + result[key] = computeThemesDiff(oldVal || [], newVal || []); + } else { + // It's a token set + result[key] = computeSetDiff(oldVal, newVal); + } + }); + + return result; +} + +/** + * Computes difference between two theme arrays. + */ +function computeThemesDiff(oldThemes, newThemes) { + const result = {}; + + const oldThemeMap = new Map((oldThemes || []).map((t) => [t.name, t])); + const newThemeMap = new Map((newThemes || []).map((t) => [t.name, t])); + + const allThemeNames = new Set([ + ...oldThemeMap.keys(), + ...newThemeMap.keys(), + ]); + + allThemeNames.forEach((name) => { + const oldTheme = oldThemeMap.get(name); + const newTheme = newThemeMap.get(name); + + if (!oldTheme && newTheme) { + result[name] = { type: "added", newValue: newTheme }; + } else if (oldTheme && !newTheme) { + result[name] = { type: "removed", oldValue: oldTheme }; + } else { + if (JSON.stringify(oldTheme) !== JSON.stringify(newTheme)) { + result[name] = { + type: "modified", + oldValue: oldTheme, + newValue: newTheme, + }; + } else { + result[name] = { + type: "unchanged", + oldValue: oldTheme, + newValue: newTheme, + }; + } + } + }); + + let type = "unchanged"; + if ( + (!oldThemes || oldThemes.length === 0) && + newThemes && + newThemes.length > 0 + ) + type = "modified"; + else if ( + oldThemes && + oldThemes.length > 0 && + (!newThemes || newThemes.length === 0) + ) + type = "modified"; + else if (Object.keys(result).some((k) => result[k].type !== "unchanged")) + type = "modified"; + + return { type, children: result }; +} + +/** + * Computes difference between two token sets. + */ +function computeSetDiff(oldSet, newSet) { + const result = {}; + const allTokenNames = new Set([ + ...Object.keys(oldSet || {}), + ...Object.keys(newSet || {}), + ]); + + allTokenNames.forEach((tokenName) => { + const oldToken = oldSet ? oldSet[tokenName] : undefined; + const newToken = newSet ? newSet[tokenName] : undefined; + + if (!oldToken && newToken) { + result[tokenName] = { type: "added", newValue: newToken }; + } else if (oldToken && !newToken) { + result[tokenName] = { type: "removed", oldValue: oldToken }; + } else { + if (JSON.stringify(oldToken) !== JSON.stringify(newToken)) { + result[tokenName] = { + type: "modified", + oldValue: oldToken, + newValue: newToken, + }; + } else { + result[tokenName] = { + type: "unchanged", + oldValue: oldToken, + newValue: newToken, + }; + } + } + }); + + let type = "unchanged"; + if (!oldSet && newSet) type = "added"; + else if (oldSet && !newSet) type = "removed"; + else if ( + Array.from(allTokenNames).some((name) => result[name].type !== "unchanged") + ) + type = "modified"; + + return { type, children: result }; +} + +/** + * Checks if the diff result contains any actual changes. + */ +export function hasChanges(diffResult) { + for (const key in diffResult) { + const setDiff = diffResult[key]; + if (setDiff.type !== "unchanged") { + if (setDiff.type === "added" || setDiff.type === "removed") return true; + if (setDiff.children) { + for (const childKey in setDiff.children) { + if (setDiff.children[childKey].type !== "unchanged") return true; + } + } + } + } + return false; +} diff --git a/src/plugin/applier.js b/src/plugin/applier.js new file mode 100644 index 0000000..04d57b5 --- /dev/null +++ b/src/plugin/applier.js @@ -0,0 +1,198 @@ +/** + * Recursively flattens nested token groups into a flat map of tokens. + * Supports both DTCG ($value, $type) and standard (value, type) formats. + * + * @param {Object} obj The token object or group. + * @param {string} prefix The current namespace prefix. + * @param {Map} result Accumulated flat tokens. + */ +function flattenTokens(obj, prefix = "", result = new Map()) { + if (!obj || typeof obj !== "object") return result; + + // Check if this object is a token leaf node + const hasValue = obj.$value !== undefined || obj.value !== undefined; + + if (hasValue) { + const value = obj.$value !== undefined ? obj.$value : obj.value; + const type = obj.$type || obj.type || "color"; + const description = obj.$description || obj.description || ""; + result.set(prefix, { value, type, description }); + } else { + // If it doesn't have a value, treat it as a group and recurse + for (const key of Object.keys(obj)) { + if (key.startsWith("$")) continue; // Skip metadata keys at group level + const newPrefix = prefix ? `${prefix}.${key}` : key; + flattenTokens(obj[key], newPrefix, result); + } + } + return result; +} + +/** + * Returns a safe, reference-free placeholder value for a given token type + * to satisfy initial validation during creation. + */ +function getDefaultValueForType(type) { + switch (type) { + case "color": + return "#000000"; + case "boolean": + return false; + case "number": + case "opacity": + return 1; + default: + return "0px"; + } +} + +/** + * Applies DTCG tokens to the Penpot library. + * @param {Object} tokens + */ +export function applyTokens(tokens) { + try { + if (!tokens) throw new Error("No token data provided"); + + const library = penpot.library.local; + const tokensCatalog = library.tokens; + + // 1. Index Existing Sets + const existingSets = new Map(); + tokensCatalog.sets.forEach((set) => { + existingSets.set(set.name, set); + }); + + // 2. Process Imported Sets + const importedSetNames = new Set(); + + for (const setName of Object.keys(tokens)) { + if (setName.startsWith("$")) continue; + importedSetNames.add(setName); + + let set = existingSets.get(setName); + if (!set) { + set = tokensCatalog.addSet({ name: setName }); + } + + if (!set) { + console.error(`Failed to create or retrieve set: ${setName}`); + continue; + } + + // Sync Tokens in Set + const setTokensJson = tokens[setName]; + const existingTokens = new Map(); + set.tokens.forEach((t) => existingTokens.set(t.name, t)); + + const importedTokens = flattenTokens(setTokensJson); + const importedTokenNames = new Set(importedTokens.keys()); + + // Pass 1: Pre-create all missing tokens with a safe reference-free value + for (const [tokenName, tokenData] of importedTokens.entries()) { + let token = existingTokens.get(tokenName); + if (!token) { + const type = tokenData.type; + let initialValue = tokenData.value; + + // If the value contains a reference, use a safe reference-free default for Pass 1 + if (typeof initialValue === "string" && initialValue.includes("{")) { + initialValue = getDefaultValueForType(type); + } + + try { + token = set.addToken({ type, name: tokenName, value: initialValue }); + existingTokens.set(tokenName, token); // Keep our map updated + } catch (e) { + console.warn(`Failed to pre-create token ${tokenName}:`, e); + } + } + } + + // Pass 2: Set the actual values (resolving any references since all tokens now exist) + for (const [tokenName, tokenData] of importedTokens.entries()) { + const token = existingTokens.get(tokenName); + if (token) { + const value = tokenData.value; + try { + if (token.value !== value) { + token.value = value; + } + if (tokenData.description) { + token.description = tokenData.description; + } + } catch (e) { + console.warn(`Failed to set actual value for token ${tokenName}:`, e); + } + } + } + + // Remove tokens not in import + set.tokens.forEach((t) => { + if (!importedTokenNames.has(t.name)) { + if (typeof t.remove === "function") t.remove(); + } + }); + } + + // 3. Remove Sets not in import + existingSets.forEach((set, name) => { + if (!importedSetNames.has(name)) { + if (typeof set.remove === "function") set.remove(); + } + }); + + // 4. Update Themes + if (tokens.$themes && Array.isArray(tokens.$themes)) { + const existingThemes = new Map(); + tokensCatalog.themes.forEach((t) => existingThemes.set(t.name, t)); + + const importedThemeNames = new Set(); + + for (const themeData of tokens.$themes) { + const themeName = themeData.name; + importedThemeNames.add(themeName); + + let theme = existingThemes.get(themeName); + if (!theme) { + try { + theme = tokensCatalog.addTheme({ + name: themeName, + group: themeData.group || "", + }); + } catch (e) { + console.error(`Failed to create theme ${themeName}:`, e); + continue; + } + } + + // Sync active sets + if (theme && themeData.selectedTokenSets) { + for (const startName of Object.keys(themeData.selectedTokenSets)) { + const targetSet = tokensCatalog.sets.find((s) => s.name === startName); + + if (targetSet) { + try { + theme.addSet(targetSet); + } catch (e) { + console.warn(`Failed adding set ${startName} to theme ${themeName}`, e); + } + } + } + } + } + + // Remove themes not in import + existingThemes.forEach((theme, name) => { + if (!importedThemeNames.has(name)) { + if (typeof theme.remove === "function") theme.remove(); + } + }); + } + + return true; + } catch (error) { + console.error("Error applying tokens:", error); + throw error; + } +} diff --git a/src/plugin/extractor.js b/src/plugin/extractor.js new file mode 100644 index 0000000..9a1f1a2 --- /dev/null +++ b/src/plugin/extractor.js @@ -0,0 +1,90 @@ +/** + * Extracts design tokens from Penpot and formats them into Tokens Studio (DTCG) format. + * @returns {Object|null} The formatted tokens or null if extraction fails. + */ +export function extractAllTokens() { + try { + const library = penpot.library.local; + + if (!library || !("tokens" in library)) { + console.warn("Tokens API not available in this Penpot environment"); + return null; + } + + const tokensCatalog = library.tokens; + + if (!tokensCatalog || !tokensCatalog.themes || !tokensCatalog.sets) { + console.log("No tokens found - file may not have any design tokens yet"); + return null; + } + + const output = {}; + const setOrder = []; + const activeSets = []; + + // Process Token Sets + for (const set of tokensCatalog.sets) { + const setName = set.name; + setOrder.push(setName); + + if (set.active) { + activeSets.push(setName); + } + + output[setName] = {}; + + if (Array.isArray(set.tokens)) { + for (const token of set.tokens) { + output[setName][token.name] = { + $value: token.value, + $type: token.type, + $description: token.description || "", + }; + } + } + } + + // Process Themes + output.$themes = tokensCatalog.themes.map((theme) => { + const selectedTokenSets = {}; + + // Handle active sets for theme + let setsSource = theme.activeSets || theme.sets || []; + + if ((!setsSource || setsSource.length === 0) && theme.active) { + setsSource = tokensCatalog.sets.filter((s) => s.active); + } + + if (Array.isArray(setsSource)) { + for (const activeSet of setsSource) { + if (activeSet && activeSet.name) { + selectedTokenSets[activeSet.name] = "enabled"; + } + } + } + + return { + id: theme.id, + name: theme.name, + group: theme.group || "", + description: "", + isSource: false, + selectedTokenSets, + }; + }); + + // Metadata + output.$metadata = { + tokenSetOrder: setOrder, + activeThemes: tokensCatalog.themes + .filter((theme) => theme.active) + .map((theme) => theme.id), + activeSets: activeSets, + }; + + return output; + } catch (error) { + console.error("Error extracting tokens:", error); + return null; + } +} diff --git a/src/plugin/index.js b/src/plugin/index.js new file mode 100644 index 0000000..0ace8c7 --- /dev/null +++ b/src/plugin/index.js @@ -0,0 +1,55 @@ +import { extractAllTokens } from "./extractor.js"; +import { applyTokens } from "./applier.js"; + +// Open the UI +penpot.ui.open("Penpot Tokens Sync", `?theme=${penpot.theme}`, { + width: 550, + height: 450, +}); + +// Handle messages from the UI +penpot.ui.onMessage((message) => { + switch (message.type) { + case "request-current-tokens": { + const tokensData = extractAllTokens(); + penpot.ui.sendMessage({ + type: "current-tokens-data", + data: tokensData || {}, + originalRequest: message.originalRequest, + provider: message.provider, + githubData: message.githubData, + }); + break; + } + + case "pull-payload": + try { + applyTokens(message.content); + penpot.ui.sendMessage({ + type: "pull-success", + provider: `Imported from ${message.provider.repository}`, + }); + } catch (e) { + penpot.ui.sendMessage({ + type: "pull-error", + error: `Import failed: ${e.message}`, + }); + } + break; + + default: + console.log("Unknown message type:", message.type); + } +}); + +// Update the theme in the UI when it changes in Penpot +penpot.on("themechange", (theme) => { + penpot.ui.sendMessage({ + source: "penpot", + type: "themechange", + theme, + }); +}); + +// Initial stats load +// No initial stats sent anymore diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..7c9622e --- /dev/null +++ b/src/style.css @@ -0,0 +1,365 @@ +:root { + --primary: #5952ee; + --primary-hover: #4a44d6; + --bg-main: #ffffff; + --bg-secondary: #f8f9fa; + --text-main: #1a1a1a; + --text-secondary: #6c757d; + --border: #e9ecef; + --success: #28a745; + --danger: #dc3545; + --warning: #ffc107; + --radius: 8px; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + + --diff-added-bg: #e6ffed; + --diff-added-text: #24292e; + --diff-removed-bg: #ffeef0; + --diff-removed-text: #24292e; + --diff-modified-bg: #fffdef; +} + +[data-theme="dark"] { + --primary: #7c75ff; + --primary-hover: #9691ff; + --bg-main: #18191c; + --bg-secondary: #1f2125; + --text-main: #e1e3e6; + --text-secondary: #9aa0a6; + --border: #323639; + --success: #34c759; + --danger: #ff453a; + + --diff-added-bg: #1c3326; + --diff-added-text: #e6ffed; + --diff-removed-bg: #3c1e21; + --diff-removed-text: #ffeef0; + --diff-modified-bg: #3c3218; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background: var(--bg-main); + color: var(--text-main); + line-height: 1.5; + font-size: 14px; +} + +/* Typography */ +h1 { font-size: 18px; font-weight: 600; margin-bottom: 4px; } +h2 { font-size: 14px; font-weight: 600; margin-bottom: 12px; } +.subtitle { color: var(--text-secondary); font-size: 13px; } + +/* Layout */ +.view { + padding: 24px; + max-width: 600px; + margin: 0 auto; +} + +.view-header { + margin-bottom: 24px; +} + +/* Buttons */ +button { + cursor: pointer; + border-radius: var(--radius); + font-family: inherit; + font-weight: 500; + transition: all 0.2s; + border: 1px solid transparent; +} + +.primary-btn { + background: var(--primary); + color: white; + padding: 10px 16px; + width: 100%; + font-size: 14px; +} + +.primary-btn:hover:not(:disabled) { + background: var(--primary-hover); +} + +.secondary-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-main); + padding: 10px 16px; +} + +.secondary-btn:hover:not(:disabled) { + background: var(--bg-secondary); +} + +.secondary-btn.small { + padding: 6px 12px; + font-size: 12px; +} + +.primary-btn:disabled, .secondary-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +/* Providers List */ +.providers-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.provider-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.provider-info { + display: flex; + gap: 12px; + align-items: center; +} + +.provider-icon { font-size: 20px; } + +.provider-name { font-weight: 600; } +.provider-path { font-size: 12px; color: var(--text-secondary); } + +.provider-actions { + display: flex; + gap: 6px; +} + +.provider-actions button { + padding: 6px 10px; + font-size: 12px; + background: var(--bg-main); + border: 1px solid var(--border); +} + +.provider-actions button:hover { + border-color: var(--primary); +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + color: var(--text-secondary); +} + +input, textarea { + width: 100%; + padding: 10px; + background: var(--bg-main); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-main); + font-family: inherit; +} + +input:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(89, 82, 238, 0.1); +} + +.form-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 12px; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.help-text { + font-size: 11px; + color: var(--text-secondary); + margin-top: 4px; +} + +/* Modals */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-content { + background: var(--bg-main); + border-radius: 12px; + width: 90%; + max-width: 450px; + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.modal-content.large { + max-width: 500px; +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.close-btn { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); +} + +.modal-body { + padding: 20px; + overflow-y: auto; +} + +.modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* Diff Styling */ +.diff-scroll-area { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 12px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.diff-category { + margin-bottom: 20px; +} + +.diff-category-header { + font-weight: bold; + font-size: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + color: var(--primary); +} + +.diff-item { + font-size: 12px; + padding: 4px 8px; + margin-bottom: 4px; + border-radius: 4px; +} + +.diff-added { background: var(--diff-added-bg); color: var(--diff-added-text); } +.diff-removed { background: var(--diff-removed-bg); color: var(--diff-removed-text); } +.diff-modified { background: var(--diff-modified-bg); } + +.diff-name { font-weight: 600; margin-bottom: 2px; } +.diff-value { font-size: 11px; white-space: pre-wrap; word-break: break-all; } + +.diff-modified-row { + display: flex; + align-items: center; + gap: 8px; +} + +.diff-arrow { color: var(--text-secondary); } +.old-val { color: var(--danger); text-decoration: line-through; } +.new-val { color: var(--success); } + +/* Context Menu */ +.context-menu { + position: absolute; + background: var(--bg-main); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + border-radius: 6px; + padding: 4px; + z-index: 1000; +} + +.context-menu button { + display: block; + width: 100%; + padding: 8px 12px; + text-align: left; + background: none; + font-size: 13px; + border-radius: 4px; +} + +.context-menu button:hover { + background: var(--bg-secondary); +} + +.context-menu button.danger { + color: var(--danger); +} + +/* Status Bar */ +.status { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 8px 16px; + font-size: 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + min-height: 32px; + z-index: 50; +} + +.status.success { color: var(--success); font-weight: 500; } +.status.error { color: var(--danger); font-weight: 500; } + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px dashed var(--border); + border-radius: var(--radius); +} \ No newline at end of file diff --git a/src/ui/diff-renderer.js b/src/ui/diff-renderer.js new file mode 100644 index 0000000..371d9ec --- /dev/null +++ b/src/ui/diff-renderer.js @@ -0,0 +1,68 @@ +import { escapeHtml } from "./ui-utils.js"; +import { hasChanges } from "../common/diff.js"; + +/** + * Renders the semantic diff into the results container. + */ +export function renderDiff(diff, container) { + if (!container) return; + container.innerHTML = ""; + + if (!hasChanges(diff)) { + container.innerHTML = + '
No changes detected. Everything is up to date!
'; + return; + } + + for (const [setName, result] of Object.entries(diff)) { + if (result.type === "unchanged") continue; + + const categoryEl = document.createElement("div"); + categoryEl.className = "diff-category"; + + const headerEl = document.createElement("div"); + headerEl.className = "diff-category-header"; + + let badgeHtml = ""; + if (result.type === "added") + badgeHtml = 'New'; + if (result.type === "removed") + badgeHtml = 'Removed'; + + headerEl.innerHTML = `Set: ${escapeHtml(setName)} ${badgeHtml}`; + categoryEl.appendChild(headerEl); + + if (result.children) { + for (const [itemName, childResult] of Object.entries(result.children)) { + if (childResult.type === "unchanged") continue; + + const itemEl = document.createElement("div"); + itemEl.className = `diff-item diff-${childResult.type}`; + + let contentHtml = `
${escapeHtml(itemName)}
`; + + if (childResult.type === "added") { + const val = childResult.newValue?.$value ?? JSON.stringify(childResult.newValue); + contentHtml += `
+ ${escapeHtml(String(val))}
`; + } else if (childResult.type === "removed") { + const val = childResult.oldValue?.$value ?? JSON.stringify(childResult.oldValue); + contentHtml += `
- ${escapeHtml(String(val))}
`; + } else if (childResult.type === "modified") { + const oldVal = childResult.oldValue?.$value ?? JSON.stringify(childResult.oldValue); + const newVal = childResult.newValue?.$value ?? JSON.stringify(childResult.newValue); + contentHtml += ` +
+
${escapeHtml(String(oldVal))}
+
โ†’
+
${escapeHtml(String(newVal))}
+
+ `; + } + itemEl.innerHTML = contentHtml; + categoryEl.appendChild(itemEl); + } + } + + container.appendChild(categoryEl); + } +} diff --git a/src/ui/index.js b/src/ui/index.js new file mode 100644 index 0000000..7743b7d --- /dev/null +++ b/src/ui/index.js @@ -0,0 +1,307 @@ +import "../style.css"; +import { computeSemanticDiff } from "../common/diff.js"; +import { loadProviders, saveProviders, generateId } from "./services/storage.js"; +import { getFileFromGitHub, pushFileToGitHub } from "./services/github.js"; +import { + showStatus, + toggleButtonLoading, + downloadJSON, + escapeHtml +} from "./ui-utils.js"; +import { renderDiff } from "./diff-renderer.js"; + +// DOM - State +let providers = []; +let editingProviderId = null; +let pendingDiffOperation = null; + +// DOM Elements +const elements = { + status: document.getElementById("status"), + mainView: document.getElementById("main-view"), + syncFormView: document.getElementById("sync-form-view"), + addSyncBtn: document.getElementById("add-sync-btn"), + cancelSyncBtn: document.getElementById("cancel-sync"), + githubSyncForm: document.getElementById("github-sync-form"), + providersList: document.getElementById("providers-list"), + formTitle: document.getElementById("form-title"), + + // Modals + diffModal: document.getElementById("diff-modal"), + diffTitle: document.getElementById("diff-title"), + diffMessage: document.getElementById("diff-message"), + diffResults: document.getElementById("diff-results"), + closeDiffBtn: document.getElementById("close-diff-btn"), + cancelDiffBtn: document.getElementById("cancel-diff-btn"), + confirmDiffBtn: document.getElementById("confirm-diff-btn"), + + commitModal: document.getElementById("commit-modal"), + closeCommitBtn: document.getElementById("close-commit-btn"), + cancelCommitBtn: document.getElementById("cancel-commit-btn"), + confirmCommitBtn: document.getElementById("confirm-commit-btn"), + commitMessageInput: document.getElementById("commit-message-input"), +}; + +// Initial Theme +const searchParams = new URLSearchParams(window.location.search); +document.body.dataset.theme = searchParams.get("theme") || "light"; + +/** + * Views Management + */ +function showView(viewName) { + elements.mainView.style.display = viewName === "main" ? "block" : "none"; + elements.syncFormView.style.display = viewName === "form" ? "block" : "none"; + if (viewName === "main") editingProviderId = null; +} + +/** + * Provider Rendering + */ +function renderProviderList() { + if (!elements.providersList) return; + + if (providers.length === 0) { + elements.providersList.innerHTML = '
No sync providers added yet.
'; + return; + } + + elements.providersList.innerHTML = providers.map(p => ` +
+
+
๐Ÿ“
+
+
${escapeHtml(p.name)}
+
${escapeHtml(p.repository)} (${escapeHtml(p.branch)})
+
+
+
+ + + +
+
+ `).join(""); + + attachProviderEvents(); +} + +function attachProviderEvents() { + elements.providersList.querySelectorAll(".pull-btn").forEach(btn => + btn.addEventListener("click", () => handlePullRequest(btn.dataset.id))); + + elements.providersList.querySelectorAll(".push-btn").forEach(btn => + btn.addEventListener("click", () => handlePushRequest(btn.dataset.id))); + + elements.providersList.querySelectorAll(".menu-btn").forEach(btn => + btn.addEventListener("click", (e) => showContextMenu(e, btn.dataset.id))); +} + +/** + * Handlers - GitHub Operations + */ +async function handlePullRequest(id) { + const provider = providers.find(p => p.id === id); + const btn = document.querySelector(`.pull-btn[data-id="${id}"]`); + toggleButtonLoading(btn, true, "โ†“ Pull"); + + try { + showStatus("โณ Fetching from GitHub...", "success"); + const githubFile = await getFileFromGitHub(provider); + + // Request current Penpot tokens to compare + parent.postMessage({ + type: "request-current-tokens", + originalRequest: "pull", + provider, + githubData: githubFile.content + }, "*"); + } catch (error) { + showStatus(`โŒ ${error.message}`, "error"); + toggleButtonLoading(btn, false, "โ†“ Pull"); + } +} + +async function handlePushRequest(id) { + const provider = providers.find(p => p.id === id); + const btn = document.querySelector(`.push-btn[data-id="${id}"]`); + toggleButtonLoading(btn, true, "โ†‘ Push"); + + parent.postMessage({ + type: "request-current-tokens", + originalRequest: "push", + provider: provider + }, "*"); +} + +/** + * Handlers - Dialogs & Modals + */ +function showContextMenu(e, id) { + document.querySelectorAll(".context-menu").forEach(m => m.remove()); + const menu = document.createElement("div"); + menu.className = "context-menu"; + menu.innerHTML = ` + + + `; + + const rect = e.target.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + menu.style.right = `${window.innerWidth - rect.right}px`; + document.body.appendChild(menu); + + menu.querySelector('[data-action="edit"]').onclick = () => { + const p = providers.find(x => x.id === id); + editingProviderId = id; + elements.formTitle.textContent = "๐Ÿ“ Edit Sync Provider"; + const form = elements.githubSyncForm; + form.name.value = p.name; + form.token.value = p.token; + form.repository.value = p.repository; + form.branch.value = p.branch; + form.storagePath.value = p.storagePath; + showView("form"); + menu.remove(); + }; + + menu.querySelector('[data-action="delete"]').onclick = () => { + if (confirm("Delete this provider?")) { + providers = providers.filter(x => x.id !== id); + saveProviders(providers); + renderProviderList(); + } + menu.remove(); + }; + + setTimeout(() => document.addEventListener("click", () => menu.remove(), { once: true }), 0); +} + +function closeModals() { + elements.diffModal.style.display = "none"; + elements.commitModal.style.display = "none"; + pendingDiffOperation = null; + // Reset all loading states + document.querySelectorAll(".pull-btn, .push-btn").forEach(btn => { + btn.disabled = false; + btn.textContent = btn.dataset.originalText || btn.textContent; + }); +} + +/** + * Message Handling + */ +window.addEventListener("message", async (event) => { + const msg = event.data; + + if (msg.source === "penpot") { + document.body.dataset.theme = msg.theme; + } else if (msg.type === "current-tokens-data") { + const penpotTokens = msg.data || {}; + + if (msg.originalRequest === "pull") { + const githubData = msg.githubData || {}; + const diff = computeSemanticDiff(penpotTokens, githubData); + pendingDiffOperation = { type: "pull", provider: msg.provider, newData: githubData }; + + elements.diffTitle.textContent = `Review Pull from ${msg.provider.repository}`; + renderDiff(diff, elements.diffResults); + elements.diffModal.style.display = "flex"; + } else if (msg.originalRequest === "push") { + try { + showStatus("โณ Comparing with remote...", "success"); + const githubFile = await getFileFromGitHub(msg.provider); + const diff = computeSemanticDiff(githubFile.content, penpotTokens); + pendingDiffOperation = { + type: "push", + provider: msg.provider, + newData: penpotTokens, + sha: githubFile.sha + }; + + elements.diffTitle.textContent = `Review Push to ${msg.provider.repository}`; + renderDiff(diff, elements.diffResults); + elements.diffModal.style.display = "flex"; + } catch (e) { + showStatus(`โŒ ${e.message}`, "error"); + closeModals(); + } + } + } else if (msg.type === "pull-success") { + showStatus(msg.provider, "success"); + closeModals(); + } else if (msg.type === "pull-error") { + showStatus(msg.error, "error"); + closeModals(); + } +}); + +/** + * Event Listeners + */ +elements.addSyncBtn.onclick = () => { + elements.formTitle.textContent = "๐Ÿ“ฆ Add Sync Provider"; + elements.githubSyncForm.reset(); + showView("form"); +}; + +elements.cancelSyncBtn.onclick = () => showView("main"); + +elements.githubSyncForm.onsubmit = (e) => { + e.preventDefault(); + const fd = new FormData(elements.githubSyncForm); + const data = Object.fromEntries(fd.entries()); + + if (editingProviderId) { + const idx = providers.findIndex(p => p.id === editingProviderId); + providers[idx] = { id: editingProviderId, ...data }; + } else { + providers.push({ id: generateId(), ...data }); + } + + saveProviders(providers); + renderProviderList(); + showView("main"); +}; + +// Modal Actions +elements.closeDiffBtn.onclick = closeModals; +elements.cancelDiffBtn.onclick = closeModals; +elements.closeCommitBtn.onclick = closeModals; +elements.cancelCommitBtn.onclick = closeModals; + +elements.confirmDiffBtn.onclick = () => { + if (!pendingDiffOperation) return; + if (pendingDiffOperation.type === "pull") { + showStatus("โณ Syncing to Penpot...", "success"); + parent.postMessage({ + type: "pull-payload", + content: pendingDiffOperation.newData, + provider: pendingDiffOperation.provider + }, "*"); + } else { + elements.diffModal.style.display = "none"; + elements.commitModal.style.display = "flex"; + } +}; + +elements.confirmCommitBtn.onclick = async () => { + const op = pendingDiffOperation; + const msg = elements.commitMessageInput.value.trim() || `Update tokens from Penpot (${new Date().toISOString()})`; + + try { + showStatus("โณ Pushing to GitHub...", "success"); + elements.confirmCommitBtn.disabled = true; + await pushFileToGitHub(op.provider, JSON.stringify(op.newData, null, 2), msg, op.sha); + showStatus("โœ… Push successful!", "success"); + } catch (e) { + showStatus(`โŒ ${e.message}`, "error"); + } finally { + elements.confirmCommitBtn.disabled = false; + closeModals(); + } +}; + +// Init +providers = loadProviders(); +renderProviderList(); diff --git a/src/ui/services/github.js b/src/ui/services/github.js new file mode 100644 index 0000000..7d3b752 --- /dev/null +++ b/src/ui/services/github.js @@ -0,0 +1,87 @@ +const GITHUB_API_BASE = "https://api.github.com"; + +/** + * Fetches file content from GitHub. + */ +export async function getFileFromGitHub(provider) { + const { repository, branch, storagePath, token } = provider; + const [owner, repo] = repository.split("/"); + + if (!owner || !repo) { + throw new Error("Invalid repository format (expected owner/repo)"); + } + + const cleanToken = token.trim(); + const response = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${storagePath}?ref=${branch}`, + { + method: "GET", + headers: { + Authorization: `token ${cleanToken}`, + Accept: "application/vnd.github.v3+json", + }, + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("File not found in repository."); + } else if (response.status === 401) { + throw new Error("Unauthorized. Check your GitHub token."); + } else { + throw new Error(`GitHub error: ${response.status}`); + } + } + + const data = await response.json(); + const rawContent = data.content.replace(/\n/g, ""); + const decodedContent = decodeURIComponent(escape(window.atob(rawContent))); + + return { + content: JSON.parse(decodedContent), + sha: data.sha, + }; +} + +/** + * Pushes file content to GitHub. + */ +export async function pushFileToGitHub(provider, content, commitMessage, sha) { + const { repository, branch, storagePath, token } = provider; + const [owner, repo] = repository.split("/"); + const cleanToken = token.trim(); + + // Robust UTF-8 to Base64 conversion + const base64Content = window.btoa( + encodeURIComponent(content).replace(/%([0-9A-F]{2})/g, (_match, p1) => { + return String.fromCharCode(parseInt(p1, 16)); + }) + ); + + const payload = { + message: commitMessage, + content: base64Content, + branch, + ...(sha && { sha }), + }; + + const response = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${storagePath}`, + { + method: "PUT", + headers: { + Authorization: `token ${cleanToken}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || `Push failed (${response.status})`); + } + + return response.json(); +} diff --git a/src/ui/services/storage.js b/src/ui/services/storage.js new file mode 100644 index 0000000..f363b78 --- /dev/null +++ b/src/ui/services/storage.js @@ -0,0 +1,54 @@ +const STORAGE_KEY = "github-sync-providers"; +const OLD_STORAGE_KEY = "github-sync-config"; + +/** + * Loads sync providers from localStorage. + * Handles migration from legacy format. + * @returns {Array} List of providers. + */ +export function loadProviders() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + console.error("Failed to parse providers from storage:", e); + return []; + } + } + + // Migrate from old format + const oldConfig = localStorage.getItem(OLD_STORAGE_KEY); + if (oldConfig) { + try { + const config = JSON.parse(oldConfig); + const migratedProvider = { + id: generateId(), + ...config, + }; + const providers = [migratedProvider]; + saveProviders(providers); + localStorage.removeItem(OLD_STORAGE_KEY); + return providers; + } catch (e) { + console.error("Failed to migrate legacy config:", e); + } + } + + return []; +} + +/** + * Saves providers to localStorage. + * @param {Array} providers + */ +export function saveProviders(providers) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(providers)); +} + +/** + * Generates a unique ID for a provider. + */ +export function generateId() { + return `provider-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/src/ui/ui-utils.js b/src/ui/ui-utils.js new file mode 100644 index 0000000..dd7cf18 --- /dev/null +++ b/src/ui/ui-utils.js @@ -0,0 +1,67 @@ +/** + * Escapes HTML characters in a string. + * @param {string} text + * @returns {string} + */ +export function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +/** + * Shows a status message in the UI. + * @param {string} message + * @param {string} type 'success' | 'error' + */ +export function showStatus(message, type) { + const statusEl = document.getElementById("status"); + if (statusEl) { + statusEl.textContent = message; + statusEl.className = `status ${type}`; + + // Clear after timeout + if (statusEl.statusTimeout) clearTimeout(statusEl.statusTimeout); + statusEl.statusTimeout = setTimeout(() => { + statusEl.textContent = ""; + statusEl.className = "status"; + }, 5000); + } +} + +/** + * Toggles a loading state on a button. + */ +export function toggleButtonLoading(btn, isLoading, originalText = "โ†‘ Push") { + if (isLoading) { + btn.disabled = true; + btn.dataset.originalText = btn.textContent || originalText; + btn.textContent = "โณ"; + } else { + btn.disabled = false; + btn.textContent = btn.dataset.originalText || originalText; + } +} + +/** + * Downloads a JSON file in the browser. + */ +export function downloadJSON(data, filename) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + + setTimeout(() => { + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + }, 10); +} diff --git a/test-github.js b/test-github.js new file mode 100644 index 0000000..d805576 --- /dev/null +++ b/test-github.js @@ -0,0 +1,168 @@ +// GitHub API Test Module (Octokit Version) +// Run with: node test-github.js + +// ============ CONFIGURATION ============ +// Replace these with your actual values +const CONFIG = { + token: '', + repository: 'akshay-gupta7/test-tokens-sync', + branch: 'master', + filePath: 'tokens8.json', +}; + +// Import Octokit +import { Octokit } from '@octokit/rest'; + +// ============ TEST DATA ============ +const testTokens = { + themes: [ + { + id: 'test-theme-1', + name: 'Test Theme', + active: true, + activeSets: ['test-set-1'] + } + ], + sets: [ + { + id: 'test-set-1', + name: 'Test Set', + active: true, + tokens: [ + { + id: 'test-token-1', + name: 'primary-color', + type: 'color', + value: '#FF0000' + } + ] + } + ], + metadata: { + themesCount: 1, + setsCount: 1, + totalTokens: 1, + exportedAt: new Date().toISOString() + } +}; + +// ============ GITHUB API FUNCTIONS ============ + +async function pushToGitHub(repository, branch, filePath, content, token) { + const [owner, repo] = repository.split('/'); + + if (!owner || !repo) { + throw new Error('Invalid repository format. Use: owner/repo'); + } + + console.log('\n=== PUSH ATTEMPT (Octokit) ==='); + console.log('Repository:', `${owner}/${repo}`); + console.log('Branch:', branch); + console.log('File path:', filePath); + + // Initialize Octokit + const octokit = new Octokit({ + auth: token, + userAgent: 'penpot-tokens-plugin-test' + }); + + try { + // Step 0: Authenticate User First + console.log('Verifying credentials via GET /user (Octokit)...'); + const { data: user } = await octokit.rest.users.getAuthenticated(); + console.log(`โœ… Authenticated as user: ${user.login}`); + + // Step 1: Try to get current file SHA (for update) + let sha = undefined; + console.log('\nStep 1: Checking if file exists...'); + + try { + const { data: fileData } = await octokit.rest.repos.getContent({ + owner, + repo, + path: filePath, + ref: branch, + }); + + if (Array.isArray(fileData)) { + throw new Error('Path is a directory, not a file'); + } + + sha = fileData.sha; + console.log('โœ… File exists, SHA:', sha); + } catch (error) { + if (error.status === 404) { + console.log('โœ… File does not exist, will create new file'); + } else { + throw error; + } + } + + // Step 2: Create or update file + console.log('\nStep 2: Pushing file to GitHub...'); + + // Encode content to Base64 + // Node.js uses Buffer + const base64Content = Buffer.from(content).toString('base64'); + + const { data: commit } = await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path: filePath, + message: `Update tokens via Octokit (${new Date().toISOString()})`, + content: base64Content, + branch, + ...(sha && { sha }), + }); + + console.log('\n=== PUSH SUCCESS ==='); + console.log('Commit SHA:', commit.commit.sha); + console.log('File URL:', commit.content.html_url); + console.log('\nโœ… Successfully pushed to GitHub!'); + + return commit; + + } catch (error) { + console.error('\n=== PUSH ERROR ==='); + console.error('Error:', error.message); + if (error.response) { + console.error('Status:', error.status); + console.error('Response data:', error.response.data); + } + throw error; + } +} + + +// ============ RUN TEST ============ + +async function runTest() { + console.log('=========================================='); + console.log(' GitHub API Push Test (Octokit)'); + console.log('=========================================='); + + try { + const content = JSON.stringify(testTokens, null, 2); + + await pushToGitHub( + CONFIG.repository, + CONFIG.branch, + CONFIG.filePath, + JSON.stringify(testTokens, null, 2), + CONFIG.token + ); + + console.log('\n=========================================='); + console.log('โœ… TEST PASSED - Octokit is working!'); + console.log('==========================================\n'); + + } catch (error) { + console.error('\n=========================================='); + console.error('โŒ TEST FAILED'); + console.error('=========================================='); + process.exit(1); + } +} + +// Run the test +runTest(); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..973bdfd --- /dev/null +++ b/vite.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from "vite"; +import livePreview from "vite-live-preview"; + +export default defineConfig({ + plugins: [ + livePreview({ + reload: true, + config: { + build: { + sourcemap: true, + }, + }, + }), + ], + build: { + rollupOptions: { + input: { + plugin: "src/plugin/index.js", + index: "./index.html", + }, + output: { + entryFileNames: "[name].js", + }, + }, + }, + preview: { + port: 4400, + cors: true, + }, +});