From 41ce98bd792b65be8cf4d38389d785372e3b2bf6 Mon Sep 17 00:00:00 2001 From: JulianGaibler Date: Thu, 10 Oct 2024 20:51:23 +0200 Subject: [PATCH] :tada: Initial commit --- .eslintignore | 3 + .eslintrc.cjs | 18 + .gitattributes | 2 + .github/CODEOWNERS | 1 + .github/workflows/apply-import.yaml | 31 + .github/workflows/import-check.yaml | 29 + .gitignore | 2 + .prettierignore | 3 + .prettierrc.cjs | 7 + LICENSE | 373 ++++ README.md | 78 + config/config.yaml | 41 + config/hcm.yaml | 115 ++ config/operating-system.yaml | 69 + config/surface.yaml | 20 + dist/Config.js | 98 + dist/UpdateConstructor.js | 260 +++ dist/action.yaml | 21 + dist/central-import.js | 106 + dist/imports.js | 5 + dist/index.js | 46 + dist/transform/modeDefinitions.js | 21 + dist/transform/updateVariables.js | 83 + dist/transform/variableDefinitions.js | 70 + dist/types.js | 1 + dist/utils.js | 103 + dist/workflow/index.js | 274 +++ dist/workflow/summary.js | 155 ++ package-lock.json | 2700 +++++++++++++++++++++++++ package.json | 30 + src/Config.ts | 136 ++ src/UpdateConstructor.ts | 419 ++++ src/action.yaml | 21 + src/central-import.ts | 206 ++ src/imports.ts | 13 + src/index.d.ts | 0 src/index.ts | 73 + src/transform/modeDefinitions.ts | 50 + src/transform/updateVariables.ts | 181 ++ src/transform/variableDefinitions.ts | 134 ++ src/types.ts | 84 + src/utils.ts | 145 ++ src/workflow/index.ts | 350 ++++ src/workflow/summary.ts | 409 ++++ tsconfig.json | 21 + 45 files changed, 7007 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.cjs create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/apply-import.yaml create mode 100644 .github/workflows/import-check.yaml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.cjs create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/config.yaml create mode 100644 config/hcm.yaml create mode 100644 config/operating-system.yaml create mode 100644 config/surface.yaml create mode 100644 dist/Config.js create mode 100644 dist/UpdateConstructor.js create mode 100644 dist/action.yaml create mode 100644 dist/central-import.js create mode 100644 dist/imports.js create mode 100644 dist/index.js create mode 100644 dist/transform/modeDefinitions.js create mode 100644 dist/transform/updateVariables.js create mode 100644 dist/transform/variableDefinitions.js create mode 100644 dist/types.js create mode 100644 dist/utils.js create mode 100644 dist/workflow/index.js create mode 100644 dist/workflow/summary.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/Config.ts create mode 100644 src/UpdateConstructor.ts create mode 100644 src/action.yaml create mode 100644 src/central-import.ts create mode 100644 src/imports.ts create mode 100644 src/index.d.ts create mode 100644 src/index.ts create mode 100644 src/transform/modeDefinitions.ts create mode 100644 src/transform/updateVariables.ts create mode 100644 src/transform/variableDefinitions.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 src/workflow/index.ts create mode 100644 src/workflow/summary.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..2f9680a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules +dist/ +.DS_Store diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..38e3b61 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,18 @@ +/* eslint-disable no-undef */ +// eslint-disable-next-line no-undef +module.exports = { + env: { + es2021: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, + plugins: ['@typescript-eslint'], +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..63a9ae0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @cwzilla @jsimplicio @JulianGaibler diff --git a/.github/workflows/apply-import.yaml b/.github/workflows/apply-import.yaml new file mode 100644 index 0000000..f27cdfb --- /dev/null +++ b/.github/workflows/apply-import.yaml @@ -0,0 +1,31 @@ +name: 'Variable Import Check (apply to file)' + +on: + workflow_dispatch: + inputs: + figmaUrl: + type: 'string' + description: 'URL of the branch to import and update variables' + required: true + +jobs: + checks: + runs-on: 'ubuntu-latest' + environment: 'Actions' + name: 'Check if there are potential updates to the Figma Desktop Styles' + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + - name: Install dependencies + run: npm ci --production + - name: Check for updates and apply them + uses: './dist' + with: + FIGMA_URL: '${{ github.event.inputs.figmaUrl }}' + FIGMA_ACCESS_TOKEN: '${{ secrets.FIGMA_ACCESS_TOKEN }}' + SLACK_WEBHOOK_FAILURE: '${{ secrets.SLACK_WEBHOOK_FAILURE }}' + DRY_RUN: 'false' diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml new file mode 100644 index 0000000..5f8421d --- /dev/null +++ b/.github/workflows/import-check.yaml @@ -0,0 +1,29 @@ +name: 'Variable Import Check (read-only)' + +on: + schedule: + - cron: '0 16 * * 2' # Every Tuesday at 4 PM UTC + workflow_dispatch: # Allows manual trigger + +jobs: + checks: + runs-on: 'ubuntu-latest' + environment: 'Actions' + name: 'Check if there are potential updates to the Figma Desktop Styles' + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + - name: Install dependencies + run: npm ci --production + - name: Check for updates + uses: './dist' + with: + FIGMA_URL: ${{ secrets.MAIN_FIGMA_URL }} + FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} + SLACK_WEBHOOK_SUCCESS: ${{ secrets.SLACK_WEBHOOK_SUCCESS }} + SLACK_WEBHOOK_FAILURE: ${{ secrets.SLACK_WEBHOOK_FAILURE }} + DRY_RUN: 'true' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2f9680a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules +dist/ +.DS_Store diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..467ad32 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + semi: false, + trailingComma: 'all', + singleQuote: true, + printWidth: 80, + tabWidth: 2, +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47d00be --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# figma-variable-import + +This repository contains a script to fetch design tokens from Mozilla Central and import them into the Firefox Figma library. The script helps keep the Figma design files synchronized with the latest design tokens used across Firefox, ensuring consistency in design systems. + +## Workflow Overview + +### Automated Sync via GitHub Actions + +Every Tuesday at 4 PM UTC, a GitHub Action automatically checks for changes in the design tokens from Mozilla Central. If changes are detected, the following steps occur: +1. A Slack Workflow is triggered via webhook, sending a message team that there are outstanding changes. +2. The team must manually apply these changes by following the steps outlined below. + +### Applying Changes to the Figma File + +1. **Create a New Branch:** + - Before applying the token updates, create a new branch from the file containing the relevant Figma styles. + +2. **Trigger the Workflow:** + - Once the new branch is ready, manually trigger the corresponding GitHub workflow by providing the URL of the workflow as an argument. This workflow will apply the token changes to the branch. +3. **Review Changes:** + - After the workflow completes, review the changes in the Figma file to ensure they are correct. + +(**Note:** If it were possible in the future to generate branches automatically and request a review through the Figma REST API, it could be done in one step.) + +## Development + +For local development, follow these steps: + +1. **Install Dependencies:** + ```bash + npm install + ``` +2. **Run the Script Locally:** You can run the script locally for testing or development purposes using: + ```bash + npm run start + ``` +3. **Build Before Committing:** Before committing any changes, ensure the script is built: + ```bash + npm run build + ``` + +### Token yaml format + +The script uses a custom format to ingest the tokens. Each file is for a Figma collection, each collection contains a variable, each variable contains a mode, and each mode contains a value. Values can be aliased to other values with the syntax `{collection$variableName}`. + +Example: +```yaml +path/name/for/variable: + Mode1: 123 + Mode2: 456 +another/path/for/variable: + Mode1: 123 + Mode2: '{other collection$path/name/for/variable}' +``` + +Variables need to be imported in `imports.ts` and then added in the `index.ts` file. The central tokens first get normalized into this format in `central-import.ts`. + +### Notable files +Some short descriptions of some noteworthy files in the repository to help navigate the codebase. + +```bash +├── config +│ ├── config.yaml # Configuration file for the action +│ ├── hcm.yaml # Additional tokens for HCM +│ ├── operating-system.yaml # Additional tokens for different operating systems +│ └── surface.yaml # Additional tokens for different surfaces +└── src + ├── Config.ts # Loads the config file and ENV variables + ├── UpdateConstructor.ts # Class to construct and submit the Figma API call + ├── action.yaml # Configuration for the github action + ├── central-import.ts # Responsible for downloading and normalizing the central tokens file + ├── imports.ts # file that imports other tokens yaml files + ├── transform + │ ├── modeDefinitions.ts # 1. Determines which modes need to be added + │ ├── variableDefinitions.ts # 2. Determines which variables need to be added or deprecated + │ └── updateVariables.ts # 3. Updates the value of the variables if neccessary + └── workflow # Utilities for the github actions +``` \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..65dc190 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,41 @@ +# Configuration file for the import + +######################### +# Central import config # +######################### + +# When encountering a 'color-mix(in srgb, currentColor 7%, transparent)' value, it gets replaced by an actual color +# The source for the currentColor value is the value of the centralCurrentColorAlias +centralCurrentColorAlias: 'text/color' +# URL for the central source of the design tokens +centralSource: 'https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/design-system/tokens-figma.json' +# The following values from the central source will overriden +centralOverrides: + 'text/color/deemphasized#forcedColors': CanvasText + 'text/color/error#forcedColors': CanvasText + +###################### +# Deprecation config # +###################### + +# These are the variables that will not be marked as +# deprecated, even though they don't exist in central +figmaOnlyVariables: + - 'border/color/overlay' + - 'border/color/default' + - 'background/color/tabstrip' + - 'background/color/toolbar' + - 'background/color/canvas-v2' + - 'background/color/backdrop' + - 'button/background/color/primary/deemphasized' +###################### +# Debugging settings # +###################### + +# You can override ENV variables here for debugging +# env: +# FIGMA_URL: 'https://www.figma.com/design/yl5Z...' +# FIGMA_ACCESS_TOKEN: 'figd_...' +# SLACK_WEBHOOK_SUCCESS: 'https://hooks.slack.com/triggers/...' +# SLACK_WEBHOOK_FAILURE: 'https://hooks.slack.com/triggers/...' +# DRY_RUN: 'true' diff --git a/config/hcm.yaml b/config/hcm.yaml new file mode 100644 index 0000000..5d49295 --- /dev/null +++ b/config/hcm.yaml @@ -0,0 +1,115 @@ +--- +ActiveText: + NightSky: '#8080FF' + Desert: '#1C5E75' + Dusk: '#70EBDE' + Win10: '#8080FF' + Win10White: '#00009F' +ButtonBorder: + NightSky: '#FFFFFF' + Desert: '#C0C0C0' + Dusk: '#FFFFFF' + Win10: '#FFFFFF' + Win10White: '#C0C0C0' +ButtonFace: + NightSky: '#000000' + Desert: '#FFFAEF' + Dusk: '#2D3236' + Win10: '#000000' + Win10White: '#FFFFFF' +ButtonText: + NightSky: '#FFEE32' + Desert: '#202020' + Dusk: '#B6F6F0' + Win10: '#FFFFFF' + Win10White: '#000000' +Canvas: + NightSky: '#000000' + Desert: '#FFFAEF' + Dusk: '#2D3236' + Win10: '#000000' + Win10White: '#FFFFFF' +CanvasText: + NightSky: '#FFFFFF' + Desert: '#3D3D3D' + Dusk: '#FFFFFF' + Win10: '#FFFF00' + Win10White: '#000000' +Field: + NightSky: '#000000' + Desert: '#FFFAEF' + Dusk: '#2D3236' + Win10: '#000000' + Win10White: '#FFFFFF' +FieldText: + NightSky: '#FFFFFF' + Desert: '#3D3D3D' + Dusk: '#FFFFFF' + Win10: '#FFFF00' + Win10White: '#000000' +GrayText: + NightSky: '#A6A6A6' + Desert: '#676767' + Dusk: '#A6A6A6' + Win10: '#00FF00' + Win10White: '#600000' +Highlight: + NightSky: '#D6B4FD' + Desert: '#903909' + Dusk: '#A1BFDE' + Win10: '#008000' + Win10White: '#37006E' +HighlightText: + NightSky: '#2B2B2B' + Desert: '#FFF5E3' + Dusk: '#212D3B' + Win10: '#FFFFFF' + Win10White: '#FFFFFF' +LinkText: + NightSky: '#8080FF' + Desert: '#1C5E75' + Dusk: '#70EBDE' + Win10: '#8080FF' + Win10White: '#00009F' +Mark: + NightSky: '#FFFF00' + Desert: '#FFFF00' + Dusk: '#FFFF00' + Win10: '#FFFF00' + Win10White: '#FFFF00' +MarkText: + NightSky: '#000000' + Desert: '#000000' + Dusk: '#000000' + Win10: '#000000' + Win10White: '#000000' +SelectedItem: + NightSky: '#D6B4FD' + Desert: '#903909' + Dusk: '#A1BFDE' + Win10: '#008000' + Win10White: '#37006E' +SelectedItemText: + NightSky: '#2B2B2B' + Desert: '#FFF5E3' + Dusk: '#212D3B' + Win10: '#FFFFFF' + Win10White: '#FFFFFF' +AccentColor: + NightSky: '#D6B4FD' + Desert: '#903909' + Dusk: '#A1BFDE' + Win10: '#008000' + Win10White: '#37006E' +AccentColorText: + NightSky: '#000000' + Desert: '#FFFFFF' + Dusk: '#000000' + Win10: '#FFFFFF' + Win10White: '#FFFFFF' +VisitedText: + NightSky: '#80FF80' + Desert: '#9E3D96' + Dusk: '#96FF9B' + Win10: '#80FF00' + Win10White: '#800080' diff --git a/config/operating-system.yaml b/config/operating-system.yaml new file mode 100644 index 0000000..584cd47 --- /dev/null +++ b/config/operating-system.yaml @@ -0,0 +1,69 @@ +--- +system/is-mac: + Mac: true + Windows: false + LinuxUbuntu: false + LinuxKDE: false +system/is-windows: + Mac: false + Windows: true + LinuxUbuntu: false + LinuxKDE: false +system/is-linux: + Mac: false + Windows: false + LinuxUbuntu: true + LinuxKDE: true + +typography/font-family: + Mac: 'SF Pro' + Windows: 'Segoe UI' + LinuxUbuntu: 'Ubuntu Sans' + LinuxKDE: 'Noto Sans' + +typography/font-size/chrome/heading/default: + Mac: 13 + Windows: 12 + LinuxUbuntu: 14.6667 + LinuxKDE: 13.3333 +typography/font-size/chrome/body/default: + Mac: 13 + Windows: 12 + LinuxUbuntu: 14.6667 + LinuxKDE: 13.3333 +typography/font-size/chrome/body/small: + Mac: 11 + Windows: 11 + LinuxUbuntu: 11 + LinuxKDE: 11 +typography/font-size/chrome/body/x-small: + Mac: 8.2 + Windows: 9 + LinuxUbuntu: 11 + LinuxKDE: 10 + +typography/font-size/in-content/title/default: + Mac: 26 + Windows: 26 + LinuxUbuntu: 26 + LinuxKDE: 26 +typography/font-size/in-content/heading/large: + Mac: 22 + Windows: 22 + LinuxUbuntu: 22 + LinuxKDE: 22 +typography/font-size/in-content/heading/default: + Mac: 17 + Windows: 17 + LinuxUbuntu: 17 + LinuxKDE: 17 +typography/font-size/in-content/body/default: + Mac: 15 + Windows: 13 + LinuxUbuntu: 13 + LinuxKDE: 13 +typography/font-size/in-content/body/small: + Mac: 13 + Windows: 13 + LinuxUbuntu: 13 + LinuxKDE: 13 \ No newline at end of file diff --git a/config/surface.yaml b/config/surface.yaml new file mode 100644 index 0000000..f89ce7a --- /dev/null +++ b/config/surface.yaml @@ -0,0 +1,20 @@ +--- + +typography/font-size/title/default: + InContent: '{Operating System$typography/font-size/in-content/title/default}' + Chrome: 1 +typography/font-size/heading/large: + InContent: '{Operating System$typography/font-size/in-content/heading/large}' + Chrome: 1 +typography/font-size/heading/default: + InContent: '{Operating System$typography/font-size/in-content/heading/default}' + Chrome: '{Operating System$typography/font-size/chrome/heading/default}' +typography/font-size/body/default: + InContent: '{Operating System$typography/font-size/in-content/body/default}' + Chrome: '{Operating System$typography/font-size/chrome/body/default}' +typography/font-size/body/small: + InContent: '{Operating System$typography/font-size/in-content/body/small}' + Chrome: '{Operating System$typography/font-size/chrome/body/small}' +typography/font-size/body/x-small: + InContent: 1 + Chrome: '{Operating System$typography/font-size/chrome/body/x-small}' \ No newline at end of file diff --git a/dist/Config.js b/dist/Config.js new file mode 100644 index 0000000..6a34b2e --- /dev/null +++ b/dist/Config.js @@ -0,0 +1,98 @@ +import { readFileSync } from 'fs'; +import YAML from 'yaml'; +const FIGMA_URL_REGEX = /https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/([\w-]+)\/([0-9a-zA-Z]{22,128}))?(?:\/.*)?$/; +class Config { + figmaFileId; + centralCurrentColorAlias; + centralSource; + centralOverrides; + figmaOnlyVariables; + figmaAccessToken; + slackWebhookUrlSuccess; + slackWebhookUrlFailure; + dryRun; + constructor() { + const config = YAML.parse(readFileSync('./config/config.yaml', 'utf8')); + if (!config.env) { + config.env = {}; + } + this.figmaFileId = this.parseFigmaUrl(config.env.FIGMA_URL || process.env.INPUT_FIGMA_URL); + this.centralCurrentColorAlias = config.centralCurrentColorAlias; + this.centralSource = config.centralSource; + this.centralOverrides = config.centralOverrides; + this.figmaOnlyVariables = config.figmaOnlyVariables; + this.figmaAccessToken = + config.env.FIGMA_ACCESS_TOKEN || process.env.INPUT_FIGMA_ACCESS_TOKEN; + this.slackWebhookUrlSuccess = + config.env.SLACK_WEBHOOK_SUCCESS || + process.env.INPUT_SLACK_WEBHOOK_SUCCESS; + this.slackWebhookUrlFailure = + config.env.SLACK_WEBHOOK_FAILURE || + process.env.INPUT_SLACK_WEBHOOK_FAILURE; + this.dryRun = + config.env.DRY_RUN === 'true' || + process.env.INPUT_DRY_RUN === 'true' || + false; + this.testConfig(); + } + parseFigmaUrl(figmaURL) { + if (!figmaURL || figmaURL === '') { + throw new Error('Error loading config: FIGMA_URL is undefined'); + } + const match = figmaURL.match(FIGMA_URL_REGEX); + if (!match) { + throw new Error('Error loading config: FIGMA_URL is not a valid Figma URL'); + } + if (match[1] !== 'design') { + throw new Error(`Error loading config: FIGMA_URL is not a design URL, it is ${match[1]}`); + } + if (match[3] && match[4] && match[3] === 'branch') { + return match[4]; + } + else { + return match[2]; + } + } + potentiallyOverride(tokenName, tokenMode) { + const searchKey = tokenMode ? `${tokenName}#${tokenMode}` : tokenName; + return this.centralOverrides[searchKey]; + } + testConfig() { + if (this.figmaFileId === undefined || this.figmaFileId === '') { + throw new Error('Error loading config: figmaFileId is undefined'); + } + if (this.centralCurrentColorAlias === undefined) { + throw new Error('Error loading config: centralCurrentColorAlias is undefined'); + } + if (this.centralSource === undefined) { + throw new Error('Error loading config: centralSource is undefined'); + } + if (this.centralOverrides === undefined) { + throw new Error('Error loading config: centralOverrides is undefined'); + } + else { + if (typeof this.centralOverrides !== 'object') { + throw new Error('Error loading config: centralOverrides is not an object'); + } + if (!Object.keys(this.centralOverrides).every((k) => typeof k === 'string')) { + throw new Error('Error loading config: centralOverrides keys are not strings'); + } + if (!Object.values(this.centralOverrides).every((v) => typeof v === 'string')) { + throw new Error('Error loading config: centralOverrides values are not strings'); + } + } + if (this.figmaOnlyVariables !== undefined) { + if (!Array.isArray(this.figmaOnlyVariables)) { + throw new Error('Error loading config: figmaOnlyVariables is not an array'); + } + if (!this.figmaOnlyVariables.every((v) => typeof v === 'string')) { + throw new Error('Error loading config: figmaOnlyVariables is not an array of strings'); + } + } + if (this.figmaAccessToken === undefined) { + throw new Error('Error loading config: figmaAccessToken is undefined'); + } + } +} +const configInstance = new Config(); +export default configInstance; diff --git a/dist/UpdateConstructor.js b/dist/UpdateConstructor.js new file mode 100644 index 0000000..067aa5d --- /dev/null +++ b/dist/UpdateConstructor.js @@ -0,0 +1,260 @@ +import { FigmaAPIURLs, SYMBOL_RESOLVED_TYPE, determineResolvedTypeWithAlias, extractAliasParts, fetchFigmaAPI, } from './utils.js'; +class UpdateConstructor { + idCounter; + changes; + extraStats; + centralTokens; + figmaTokens; + constructor(centralTokens, figmaTokens) { + this.centralTokens = inferResolvedTypes(centralTokens); + this.figmaTokens = figmaTokens; + this.idCounter = 0; + this.changes = { + variableCollections: [], + variableModes: [], + variables: [], + variableModeValues: [], + }; + this.extraStats = { + variablesDeprecated: [], + variablesUndeprecated: [], + modesCreated: [], + variablesCreated: [], + variableValuesUpdated: [], + }; + } + getChanges() { + return this.changes; + } + getStats() { + return this.extraStats; + } + hasChanges() { + return Object.keys(this.changes).some((key) => this.changes[key].length > 0); + } + getTempId() { + return `tempId${this.idCounter++}`; + } + async submitChanges(fileId) { + const changes = Object.fromEntries(Object.entries(this.changes).filter(([, value]) => value.length > 0)); + if (Object.keys(changes).length === 0) { + console.info('No changes to submit'); + return; + } + console.info('Submitting changes:', changes); + fetchFigmaAPI(FigmaAPIURLs.postVariables(fileId), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(changes), + }) + .then((result) => { + this.extraStats.result = result; + }) + .catch((error) => { + this.extraStats.result = `${error.message}\n\n${error.stack}`; + }); + } + createVariable(name, collectionLabel, resolvedType) { + const variableCollectionId = this.figmaTokens[collectionLabel].collection.id; + const tempId = this.getTempId(); + const obj = { + action: 'CREATE', + name, + id: tempId, + variableCollectionId, + resolvedType: resolvedType, + }; + this.changes.variables.push(obj); + this.figmaTokens[collectionLabel].variables.push({ + id: tempId, + name, + key: '---', + variableCollectionId, + resolvedType: resolvedType, + valuesByMode: {}, + remote: false, + description: '', + hiddenFromPublishing: false, + scopes: [], + codeSyntax: {}, + }); + this.extraStats.variablesCreated.push({ + collection: collectionLabel, + variable: name, + resolvedType, + }); + } + createVariableMode(name, collectionLabel) { + const tempId = this.getTempId(); + const obj = { + action: 'CREATE', + id: tempId, + name, + variableCollectionId: this.figmaTokens[collectionLabel].collection.id, + }; + this.changes.variableModes.push(obj); + this.figmaTokens[collectionLabel].collection.modes.push({ + modeId: tempId, + name, + }); + this.extraStats.modesCreated.push({ + collection: collectionLabel, + mode: name, + }); + } + updateVariable(update) { + const fullUpdate = { + action: 'UPDATE', + ...update, + }; + const existing = this.changes.variables.find((v) => v.id === fullUpdate.id); + if (existing) { + Object.assign(existing, fullUpdate); + } + else { + this.changes.variables.push(fullUpdate); + } + } + setVariableValue(variableId, modeId, value) { + const obj = { + variableId, + modeId, + value, + }; + this.changes.variableModeValues.push(obj); + const { collectionName, variableName, modeName, currentValue, resolvedType, } = this.reverseSearchVariableInfo(variableId, modeId); + this.extraStats.variableValuesUpdated.push({ + collection: collectionName, + variable: variableName, + mode: modeName, + oldValue: currentValue, + newValue: value, + resolvedType, + }); + } + reverseSearchVariableInfo(variableId, modeId) { + let collectionName, variableName, modeName, currentValue = null, resolvedType = null; + for (const { collection, variables } of Object.values(this.figmaTokens)) { + const variable = variables.find((v) => v.id === variableId); + if (variable) { + collectionName = collection.name; + variableName = variable.name; + modeName = collection.modes.find((m) => m.modeId === modeId)?.name; + currentValue = variable.valuesByMode[modeId]; + resolvedType = variable.resolvedType; + break; + } + } + if (!collectionName || + !variableName || + !modeName || + currentValue === null || + resolvedType === null) { + throw new Error(`When updating variable values: Could not find the collection, variable or mode for variable id '${variableId}' and mode id '${modeId}'`); + } + return { + collectionName, + variableName, + modeName, + currentValue, + resolvedType, + }; + } + setVariableAlias(variableId, modeId, aliasId) { + const obj = { + variableId, + modeId, + value: { type: 'VARIABLE_ALIAS', id: aliasId }, + }; + this.changes.variableModeValues.push(obj); + const { collectionName, variableName, modeName, currentValue, resolvedType, } = this.reverseSearchVariableInfo(variableId, modeId); + this.extraStats.variableValuesUpdated.push({ + collection: collectionName, + variable: variableName, + mode: modeName, + oldValue: currentValue, + newValue: { type: 'VARIABLE_ALIAS', id: aliasId }, + resolvedType, + }); + } + addDeprecationStat(collection, variable, deprecated) { + if (deprecated) { + this.extraStats.variablesDeprecated.push({ collection, variable }); + } + else { + this.extraStats.variablesUndeprecated.push({ collection, variable }); + } + } + getModeId(collectionLabel, modeName) { + return this.figmaTokens[collectionLabel].collection.modes.find((m) => m.name === modeName)?.modeId; + } + getVariable(collectionLabel, variableName) { + return this.figmaTokens[collectionLabel].variables.find((v) => v.name === variableName); + } + resolveCentralAlias(centralAlias) { + const aliasParts = extractAliasParts(centralAlias); + if (!aliasParts) { + throw new Error(`When resolving alias '${centralAlias}', the alias could not be parsed`); + } + const variable = this.figmaTokens[aliasParts.collection].variables.find((v) => v.name === aliasParts.variable); + if (!variable) { + throw new Error(`When resolving alias '${centralAlias}', the alias could not be found in the figma tokens`); + } + return variable; + } +} +export default UpdateConstructor; +function inferResolvedTypes(centralTokens) { + const typedCentralTokens = {}; + const queue = []; + const resolveVariableTypes = (collectionName, variableName, addToQueue) => { + const variable = centralTokens[collectionName][variableName]; + let lastResolvedType = undefined; + for (const mode in variable) { + const value = variable[mode]; + const resolvedType = determineResolvedTypeWithAlias(typedCentralTokens, value); + if (resolvedType === null) { + if (addToQueue) { + queue.push({ collectionName, variableName }); + return; + } + else { + throw new Error(`When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' could not be resolved (variable value: ${value})`); + } + } + if (lastResolvedType && lastResolvedType !== resolvedType) { + throw new Error(`When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' has conflicting types in different modes (${lastResolvedType} and ${resolvedType})`); + } + lastResolvedType = resolvedType; + } + if (!lastResolvedType) { + throw new Error(`When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' has no modes`); + } + const typedVariable = { + ...variable, + [SYMBOL_RESOLVED_TYPE]: lastResolvedType, + }; + if (!typedCentralTokens[collectionName]) { + typedCentralTokens[collectionName] = {}; + } + typedCentralTokens[collectionName][variableName] = typedVariable; + }; + for (const collectionName in centralTokens) { + const collection = centralTokens[collectionName]; + for (const variableName in collection) { + resolveVariableTypes(collectionName, variableName, true); + } + } + for (const { collectionName, variableName } of queue) { + resolveVariableTypes(collectionName, variableName, false); + } + if (queue.length > 0) { + console.warn(`WARNING: ${queue.length} variables had to be resolved in a second pass. + This happens when an alias references a variable that is defined later in the central tokens. + While this is not a problem, you might be able to optimize the order of the central tokens. + If it is not possible to optimize the order anymore, you can remove this warning!`); + } + return typedCentralTokens; +} diff --git a/dist/action.yaml b/dist/action.yaml new file mode 100644 index 0000000..7e73e34 --- /dev/null +++ b/dist/action.yaml @@ -0,0 +1,21 @@ +name: 'Central>Figma Variable Import' +description: 'Imports variables from Mozilla central to Figma' + +inputs: + FIGMA_URL: + description: 'URL of the file to import and update variables' + required: true + FIGMA_ACCESS_TOKEN: + description: 'Access token to authenticate with Figma' + required: true + SLACK_WEBHOOK_SUCCESS: + description: 'Webhook URL of a Slack workflow to notify on success' + SLACK_WEBHOOK_FAILURE: + description: 'Webhook URL of a Slack workflow to notify on failure' + DRY_RUN: + description: 'Whether to run the action without making changes' + default: 'true' + +runs: + using: 'node20' + main: 'index.js' diff --git a/dist/central-import.js b/dist/central-import.js new file mode 100644 index 0000000..f410f44 --- /dev/null +++ b/dist/central-import.js @@ -0,0 +1,106 @@ +import Config from './Config.js'; +import tinycolor from 'tinycolor2'; +export async function getCentralCollectionValues() { + return downloadFromCentral() + .then(separateCentralTokens) + .then(replaceTextColor) + .then(replaceVariableReferences); +} +async function downloadFromCentral() { + return (await fetch(Config.centralSource).then((res) => res.json())); +} +function separateCentralTokens(rawCentralTokens) { + return Object.entries(rawCentralTokens).reduce((acc, [key, value]) => { + if (typeof value === 'string') { + acc.Primitives[key] = { + Value: Config.potentiallyOverride(key) || value, + }; + } + else if ('light' in value && + 'dark' in value && + 'forcedColors' in value) { + acc.Theme[key] = { + Light: Config.potentiallyOverride(key, 'light') || value.light, + Dark: Config.potentiallyOverride(key, 'dark') || value.dark, + HCM: Config.potentiallyOverride(key, 'forcedColors') || + value.forcedColors, + }; + } + else { + throw new Error(`When separating central tokens, the value type of token '${key}' is unknown: ${JSON.stringify(value)}`); + } + return acc; + }, { Primitives: {}, Theme: {} }); +} +function replaceTextColor(tokens) { + const colorMixTf = new ColorMix(tokens.Theme, Config.centralCurrentColorAlias); + for (const [key, value] of Object.entries(tokens.Theme)) { + for (const mode of ['Light', 'Dark']) { + const color = value[mode]; + if (colorMixTf.isColorMix(color)) { + tokens.Theme[key][mode] = colorMixTf.replaceColorMix(mode, color); + } + } + } + return tokens; +} +function replaceVariableReferences(tokens) { + const primitiveLookupMap = new Map(); + for (const [key, value] of Object.entries(tokens.Primitives)) { + const tinyCurrentColor = tinycolor(value.Value); + if (!tinyCurrentColor.isValid()) { + continue; + } + primitiveLookupMap.set(tinyCurrentColor.toHex8String(), key); + } + for (const [key, value] of Object.entries(tokens.Theme)) { + for (const mode of ['Light', 'Dark', 'HCM']) { + const color = value[mode]; + if (mode === 'HCM') { + tokens.Theme[key][mode] = `{HCM Theme$${color}}`; + } + else { + const tinyCurrentColor = tinycolor(color); + if (!tinyCurrentColor.isValid()) { + continue; + } + const refVariable = primitiveLookupMap.get(tinyCurrentColor.toHex8String()); + if (refVariable) { + tokens.Theme[key][mode] = `{Primitives$${refVariable}}`; + } + } + } + } + return tokens; +} +const COLOR_MIX_REGEX = /color-mix\(in srgb, currentColor (\d+)%?, transparent\)/; +class ColorMix { + light; + dark; + constructor(collection, key) { + const colors = collection[key]; + this.light = tinycolor(colors.Light); + this.dark = tinycolor(colors.Dark); + if (!this.light.isValid()) { + throw new Error(`When initializing ColorMix, the light color is invalid: ${colors.Light}`); + } + if (!this.dark.isValid()) { + throw new Error(`When initializing ColorMix, the dark color is invalid: ${colors.Dark}`); + } + } + isColorMix(str) { + return COLOR_MIX_REGEX.test(str); + } + replaceColorMix(mode, str) { + const match = str.match(COLOR_MIX_REGEX); + if (!match) { + throw new Error(`When replacing color mix, the color mix is invalid: ${str}`); + } + const percentage = parseInt(match[1]); + const newColor = this[mode === 'Light' ? 'light' : 'dark'] + .clone() + .setAlpha(percentage / 100) + .toHex8String(); + return newColor; + } +} diff --git a/dist/imports.js b/dist/imports.js new file mode 100644 index 0000000..167eb18 --- /dev/null +++ b/dist/imports.js @@ -0,0 +1,5 @@ +import { readFileSync } from 'fs'; +import YAML from 'yaml'; +export const HCM_MAP = YAML.parse(readFileSync('./config/hcm.yaml', 'utf8')); +export const OPERATING_SYSTEM_MAP = YAML.parse(readFileSync('./config/operating-system.yaml', 'utf8')); +export const SURFACE_MAP = YAML.parse(readFileSync('./config/surface.yaml', 'utf8')); diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..aad7a7e --- /dev/null +++ b/dist/index.js @@ -0,0 +1,46 @@ +import Config from './Config.js'; +import { getCentralCollectionValues } from './central-import.js'; +import { fetchFigmaAPI, FigmaAPIURLs } from './utils.js'; +import { HCM_MAP, OPERATING_SYSTEM_MAP, SURFACE_MAP } from './imports.js'; +import UpdateConstructor from './UpdateConstructor.js'; +import { addModesDefinitions } from './transform/modeDefinitions.js'; +import { updateVariableDefinitions } from './transform/variableDefinitions.js'; +import { updateVariables } from './transform/updateVariables.js'; +import { documentError, documentStats } from './workflow/index.js'; +async function run() { + const { meta: figmaData } = await fetchFigmaAPI(FigmaAPIURLs.getVariables(Config.figmaFileId)); + const centralTokens = { + 'HCM Theme': HCM_MAP, + 'Operating System': OPERATING_SYSTEM_MAP, + ...(await getCentralCollectionValues()), + Surface: SURFACE_MAP, + }; + const figmaTokens = normalizeFigmaTokens(figmaData); + const uc = new UpdateConstructor(centralTokens, figmaTokens); + addModesDefinitions(uc); + updateVariableDefinitions(uc); + updateVariables(uc); + if (!Config.dryRun) { + uc.submitChanges(Config.figmaFileId); + } + documentStats(uc.getStats()); +} +run().catch((error) => { + documentError(error).then(() => { + throw error; + }); +}); +function normalizeFigmaTokens(figmaData) { + return Object.keys(figmaData.variableCollections).reduce((acc, key) => { + const collection = figmaData.variableCollections[key]; + if (!collection) { + throw new Error(`When normalizing Figma tokens, the collection '${key}' was not found`); + } + const variables = Object.values(figmaData.variables).filter((v) => v.variableCollectionId === collection.id); + acc[collection.name] = { + collection, + variables, + }; + return acc; + }, {}); +} diff --git a/dist/transform/modeDefinitions.js b/dist/transform/modeDefinitions.js new file mode 100644 index 0000000..2d494af --- /dev/null +++ b/dist/transform/modeDefinitions.js @@ -0,0 +1,21 @@ +export function addModesDefinitions(uc) { + for (const collectionLabel in uc.centralTokens) { + if (!uc.figmaTokens[collectionLabel]) { + throw new Error(`The collection '${collectionLabel}' is missing in the figma file. Please add it to the figma file before running the script again. +Figma collections: ${Object.keys(uc.figmaTokens).join(', ')} +Central collections: ${Object.keys(uc.centralTokens).join(', ')}`); + } + const { onlyInCentral } = generateModeSets(uc.centralTokens[collectionLabel], uc.figmaTokens[collectionLabel]); + for (const key of onlyInCentral) { + uc.createVariableMode(key, collectionLabel); + } + } +} +function generateModeSets(central, figma) { + const figmaModes = new Set(figma.collection.modes.map((m) => m.name)); + const centralKeys = new Set(Object.keys(central[Object.keys(central)[0]])); + const onlyInCentral = new Set([...centralKeys].filter((key) => !figmaModes.has(key))); + return { + onlyInCentral, + }; +} diff --git a/dist/transform/updateVariables.js b/dist/transform/updateVariables.js new file mode 100644 index 0000000..00a56b9 --- /dev/null +++ b/dist/transform/updateVariables.js @@ -0,0 +1,83 @@ +import tinycolor from 'tinycolor2'; +import { isFigmaAlias, normalizeRGBA, denormalizeRGBA, SYMBOL_RESOLVED_TYPE, isCentralAlias, } from '../utils.js'; +export function updateVariables(uc) { + for (const collectionName in uc.centralTokens) { + for (const [variableName, centralValues] of Object.entries(uc.centralTokens[collectionName])) { + for (const [modeName, centralValue] of Object.entries(centralValues)) { + const figmaVariableData = getFigmaVariableData(uc, collectionName, modeName, variableName); + const requiresUpdate = checkIfUpdateRequired(figmaVariableData, centralValue, uc, centralValues); + if (!requiresUpdate) { + continue; + } + if (isCentralAlias(centralValue)) { + const resolvedAlias = uc.resolveCentralAlias(centralValue); + if (!resolvedAlias) + throw new Error(`When resolving alias '${centralValue}' in collection '${collectionName}', the alias could not be found`); + uc.setVariableAlias(figmaVariableData.info.id, figmaVariableData.modeId, resolvedAlias.id); + continue; + } + if (centralValues[SYMBOL_RESOLVED_TYPE] === 'COLOR') { + const centralTiny = tinycolor(centralValue); + if (!centralTiny.isValid()) { + throw new Error(`When updating variables: Invalid central color value: ${centralValue} for token ${variableName} in collection ${collectionName}`); + } + uc.setVariableValue(figmaVariableData.info.id, figmaVariableData.modeId, normalizeRGBA(centralTiny.toRgb())); + continue; + } + uc.setVariableValue(figmaVariableData.info.id, figmaVariableData.modeId, centralValue); + } + } + } +} +function checkIfUpdateRequired(figmaVariableData, centralValue, uc, centralValues) { + let requiresUpdate = figmaVariableData.value === undefined; + if (!requiresUpdate) { + const isCentralValueAlias = isCentralAlias(centralValue); + const isFigmaValueAlias = isFigmaAlias(figmaVariableData.value); + if (isCentralValueAlias !== isFigmaValueAlias) { + requiresUpdate = true; + } + else if (isCentralValueAlias && isFigmaValueAlias) { + const resolveCentralAlias = uc.resolveCentralAlias(centralValue); + if (resolveCentralAlias.id !== figmaVariableData.value.id) { + requiresUpdate = true; + } + } + else if (centralValues[SYMBOL_RESOLVED_TYPE] === 'FLOAT') { + if (centralValue.toFixed(4) !== + figmaVariableData.value.toFixed(4)) { + requiresUpdate = true; + } + } + else if (centralValues[SYMBOL_RESOLVED_TYPE] !== 'COLOR') { + if (figmaVariableData.value !== centralValue) { + requiresUpdate = true; + } + } + else { + if (!figmaVariableData.value || + typeof figmaVariableData.value !== 'object' || + !('r' in figmaVariableData.value)) { + requiresUpdate = true; + } + else { + const centralTiny = tinycolor(centralValue); + const figmaTiny = tinycolor(denormalizeRGBA(figmaVariableData.value)); + if (!figmaTiny.isValid() || !tinycolor.equals(centralTiny, figmaTiny)) { + requiresUpdate = true; + } + } + } + } + return requiresUpdate; +} +function getFigmaVariableData(uc, collectionName, modeName, variableName) { + const modeId = uc.getModeId(collectionName, modeName); + if (!modeId) + throw new Error(`When updating variables: Mode ${modeName} not found in collection ${collectionName}`); + const info = uc.getVariable(collectionName, variableName); + if (!info) + throw new Error(`When updating variables: Variable ${variableName} not found in collection ${collectionName}`); + const value = info.valuesByMode[modeId]; + return { value, info, modeId }; +} diff --git a/dist/transform/variableDefinitions.js b/dist/transform/variableDefinitions.js new file mode 100644 index 0000000..2c92340 --- /dev/null +++ b/dist/transform/variableDefinitions.js @@ -0,0 +1,70 @@ +import Config from '../Config.js'; +import { SYMBOL_RESOLVED_TYPE } from '../utils.js'; +export function updateVariableDefinitions(uc) { + for (const collectionLabel in uc.centralTokens) { + const sets = generateVariableSets(uc.centralTokens[collectionLabel], uc.figmaTokens[collectionLabel]); + for (const key of sets.onlyInCentral) { + const resolvedType = uc.centralTokens[collectionLabel][key][SYMBOL_RESOLVED_TYPE]; + uc.createVariable(key, collectionLabel, resolvedType); + } + for (const key of sets.onlyInFigma) { + if (Config.figmaOnlyVariables?.includes(key)) { + continue; + } + const variableData = uc.figmaTokens[collectionLabel].variables.find((v) => v.name === key); + if (!variableData) { + throw new Error(`When adding deprecation tags, the variable ${key} could not be found in the Figma tokens`); + } + const newDescription = potentiallyAddDeprecated(variableData.description); + if (newDescription) { + uc.updateVariable({ + id: variableData.id, + description: newDescription, + }); + uc.addDeprecationStat(collectionLabel, variableData.id, true); + } + } + for (const key of sets.inBoth) { + const variableData = uc.figmaTokens[collectionLabel].variables.find((v) => v.name === key); + if (!variableData) { + throw new Error(`When removing deprecation tags, the variable ${key} could not be found in the Figma tokens`); + } + const newDescription = potentiallyRemoveDeprecated(variableData.description); + if (newDescription) { + uc.updateVariable({ + id: variableData.id, + description: newDescription, + }); + uc.addDeprecationStat(collectionLabel, variableData.id, false); + } + } + } +} +function potentiallyAddDeprecated(description) { + if (description.includes('[deprecated]')) { + return undefined; + } + return `${description.trimEnd()}\n\n[deprecated] This variable is deprecated.`.trimStart(); +} +function potentiallyRemoveDeprecated(description) { + if (!description.includes('[deprecated]')) { + return undefined; + } + return description + .split('\n') + .filter((line) => !line.includes('[deprecated]')) + .join('\n') + .trimEnd(); +} +function generateVariableSets(central, figma) { + const centralKeys = new Set(Object.keys(central)); + const figmaKeys = new Set(figma.variables.map((v) => v.name)); + const onlyInCentral = new Set([...centralKeys].filter((key) => !figmaKeys.has(key))); + const onlyInFigma = new Set([...figmaKeys].filter((key) => !centralKeys.has(key))); + const inBoth = new Set([...centralKeys].filter((key) => figmaKeys.has(key))); + return { + onlyInCentral, + onlyInFigma, + inBoth, + }; +} diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..637c28f --- /dev/null +++ b/dist/types.js @@ -0,0 +1 @@ +import { SYMBOL_RESOLVED_TYPE } from './utils.js'; diff --git a/dist/utils.js b/dist/utils.js new file mode 100644 index 0000000..96ba0f4 --- /dev/null +++ b/dist/utils.js @@ -0,0 +1,103 @@ +import tinycolor from 'tinycolor2'; +import Config from './Config.js'; +const FIGMA_API_ENDPOINT = 'https://api.figma.com'; +export const FigmaAPIURLs = { + getVariables: (fileId) => `${FIGMA_API_ENDPOINT}/v1/files/${fileId}/variables/local`, + postVariables: (fileId) => `${FIGMA_API_ENDPOINT}/v1/files/${fileId}/variables`, +}; +export async function fetchFigmaAPI(url, options = {}) { + const headers = { + 'X-FIGMA-TOKEN': Config.figmaAccessToken, + ...options.headers, + }; + const finalOptions = { + ...options, + headers, + }; + try { + const response = await fetch(url, finalOptions); + const data = await response.json(); + if (data.error === true) { + throw new Error(`When fetching Figma API, an error occurred: ${data.message}`); + } + return data; + } + catch (error) { + console.error('Error fetching Figma API:', error); + throw error; + } +} +export function roundTwoDecimals(value) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} +export function isFigmaAlias(value) { + return value !== undefined && typeof value === 'object' && 'type' in value; +} +export function normalizeRGBA(rgba) { + return { + r: rgba.r / 255, + g: rgba.g / 255, + b: rgba.b / 255, + a: roundTwoDecimals(rgba.a), + }; +} +export function denormalizeRGBA(rgba) { + return { + r: Math.floor(rgba.r * 255), + g: Math.floor(rgba.g * 255), + b: Math.floor(rgba.b * 255), + a: rgba.a, + }; +} +export const SYMBOL_RESOLVED_TYPE = Symbol('resolvedType'); +export function determineResolvedType(value) { + if (typeof value === 'boolean') { + return 'BOOLEAN'; + } + if (!isNaN(Number(value))) { + return 'FLOAT'; + } + if (tinycolor(value).isValid()) { + return 'COLOR'; + } + if (typeof value === 'string') { + return 'STRING'; + } + throw new Error(`Could not determine type for value: ${value}`); +} +const ALIAS_REGEX = /{([^$]+)\$([^}]+)}/; +export function isCentralAlias(value) { + if (typeof value !== 'string') + return false; + return ALIAS_REGEX.test(value); +} +export function extractAliasParts(value) { + if (typeof value !== 'string') + return null; + const match = ALIAS_REGEX.exec(value); + if (match) { + return { + collection: match[1], + variable: match[2], + }; + } + return null; +} +export function determineResolvedTypeWithAlias(collections, value) { + const resolvedType = determineResolvedType(value); + if (resolvedType !== 'STRING') + return resolvedType; + const aliasParts = extractAliasParts(value); + if (aliasParts) { + const { collection, variable } = aliasParts; + if (collections[collection]?.[variable]) { + return collections[collection][variable][SYMBOL_RESOLVED_TYPE]; + } + return null; + } + return resolvedType; +} +export function roundTo(value, decimals = 2) { + const factor = Math.pow(10, decimals); + return Math.round((value + Number.EPSILON) * factor) / factor; +} diff --git a/dist/workflow/index.js b/dist/workflow/index.js new file mode 100644 index 0000000..c0089b5 --- /dev/null +++ b/dist/workflow/index.js @@ -0,0 +1,274 @@ +import { denormalizeRGBA, isFigmaAlias, roundTo } from '../utils.js'; +import tinycolor from 'tinycolor2'; +import { summary } from './summary.js'; +import Config from '../Config.js'; +export async function documentStats(stats) { + setGithubWorkflowSummary(stats); + await sendSlackWorkflowStats(stats); +} +export async function documentError(error) { + setGithubWorkflowError(error); + await sendSlackWorkflowError(error); +} +export async function sendSlackWorkflowStats(stats) { + if (!Config.slackWebhookUrlSuccess) + return; + const numberStats = { + modesCreated: stats.modesCreated.length, + variablesCreated: stats.variablesCreated.length, + variableValuesUpdated: stats.variableValuesUpdated.length, + variablesDeprecated: stats.variablesDeprecated.length, + variablesUndeprecated: stats.variablesUndeprecated.length, + }; + const total = Object.values(numberStats).reduce((acc, curr) => acc + curr, 0); + if (total === 0) + return; + const payload = { + ...numberStats, + actionURL: getGithubActionURL(), + }; + return sendSlackWebhook(Config.slackWebhookUrlSuccess, payload); +} +export function setGithubWorkflowSummary(stats) { + summary.addHeading('Central>Figma Variable Import Summary', 2); + if (Config.dryRun) { + summary.addEOL().addRaw('> [!NOTE]').addEOL(); + summary + .addRaw('> This was a dry run. The changes were not submitted to Figma.') + .addEOL(); + } + else if (stats.result === undefined) { + summary.addEOL().addRaw('> [!WARNING]').addEOL(); + summary + .addRaw('> Changes were supposed to be submitted to Figma, but no result was recorded, which indicates a possible error.') + .addEOL(); + } + else if (typeof stats.result === 'object' && 'error' in stats.result) { + if (stats.result.error === true) { + summary.addEOL().addRaw('> [!CAUTION]').addEOL(); + summary + .addRaw(`> An error occurred while submitting changes to Figma. (Status code: ${stats.result.status})`) + .addEOL(); + if (stats.result.message) { + summary.addEOL().addRaw(`>`).addEOL(); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + stats.result.message.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL(); + }); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + } + } + else { + summary.addEOL().addRaw('> [!NOTE]').addEOL(); + summary + .addRaw('> Changes were submitted to Figma without any errors.') + .addEOL(); + } + } + else { + summary.addEOL().addRaw('> [!CAUTION]').addEOL(); + summary + .addRaw('> An unexpected error occurred while submitting changes to Figma.') + .addEOL(); + if (typeof stats.result === 'string') { + summary.addEOL().addRaw(`>`).addEOL(); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + stats.result.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL(); + }); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + } + } + summary.addEOL(); + summary.addHeading('Modes created', 3); + if (stats.modesCreated.length === 0) { + const element = summary.wrap('p', 'No modes were created.'); + summary.addEOL().addRaw(element).addEOL(); + } + else { + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Mode created', header: true }, + ], + ...stats.modesCreated.map((mode) => [mode.collection, mode.mode]), + ]); + } + summary.addHeading('Variables created', 3); + if (stats.variablesCreated.length === 0) { + const element = summary.wrap('p', 'No variables were created.'); + summary.addEOL().addRaw(element).addEOL(); + } + else { + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + { data: 'Type', header: true }, + ], + ...stats.variablesCreated.map((variable) => [ + variable.collection, + summary.wrap('strong', variable.variable), + variable.resolvedType, + ]), + ]); + } + summary.addHeading('Variable values updated', 3); + if (stats.variableValuesUpdated.length === 0) { + const element = summary.wrap('p', 'No variable values were updated.'); + summary.addEOL().addRaw(element).addEOL(); + } + else { + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + { data: 'Mode', header: true }, + { data: 'Old value', header: true }, + { data: 'New value', header: true }, + ], + ...stats.variableValuesUpdated.map((variable) => [ + variable.collection, + summary.wrap('strong', variable.variable), + variable.mode, + variable.oldValue !== undefined + ? summary.wrap('code', formatFigmaVariableValue(variable.oldValue, variable.resolvedType)) + : '', + summary.wrap('code', formatFigmaVariableValue(variable.newValue, variable.resolvedType)), + ]), + ]); + } + summary.addHeading('Variables deprecated', 3); + const element1 = summary.wrap('p', 'Variables where a deprecation warning was added to the description.'); + summary.addEOL().addRaw(element1).addEOL(); + if (stats.variablesDeprecated.length === 0) { + const element = summary.wrap('p', 'No variables were deprecated.'); + summary.addEOL().addRaw(element).addEOL(); + } + else { + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + ], + ...stats.variablesDeprecated.map((variable) => [ + variable.collection, + variable.variable, + ]), + ]); + } + summary.addHeading('Variables undeprecated', 3); + const element2 = summary.wrap('p', 'Variables where a deprecation warning was removed from the description.'); + summary.addEOL().addRaw(element2).addEOL; + if (stats.variablesUndeprecated.length === 0) { + const element = summary.wrap('p', 'No variables were undeprecated.'); + summary.addEOL().addRaw(element).addEOL(); + } + else { + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + ], + ...stats.variablesUndeprecated.map((variable) => [ + variable.collection, + variable.variable, + ]), + ]); + } + summary.write(); +} +function setGithubWorkflowError(error) { + const errorMessage = typeof error === 'string' + ? error + : error.stack || error.message || 'An unknown error occurred.'; + summary.addHeading('Central>Figma Variable Import Summary', 2); + summary.addEOL().addRaw('> [!CAUTION]').addEOL(); + summary.addEOL().addRaw('> An error occurred while running the script.').addEOL(); + summary.addEOL().addRaw(`>`).addEOL(); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + errorMessage.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL(); + }); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + summary.write(); +} +async function sendSlackWorkflowError(error) { + if (!Config.slackWebhookUrlFailure) + return; + const payload = { + errorMessage: typeof error === 'string' ? error : error.message, + actionURL: getGithubActionURL(), + }; + return sendSlackWebhook(Config.slackWebhookUrlFailure, payload); +} +function formatFigmaVariableValue(value, resolvedType) { + if (value === undefined) { + return '(not set)'; + } + if (isFigmaAlias(value)) { + return `ALIAS(${value.id})`; + } + if (resolvedType === 'COLOR' && typeof value === 'object' && 'r' in value) { + const denormalized = denormalizeRGBA(value); + const tinyColor = tinycolor(denormalized); + return `${tinyColor.toHexString().toUpperCase()} ${roundTo(tinyColor.getAlpha() * 100)}%`; + } + if (resolvedType === 'FLOAT') { + return roundTo(value, 4).toString(); + } + return value.toString(); +} +async function sendSlackWebhook(webookUrl, payload) { + const stringifiedPayload = Object.entries(payload).reduce((acc, [key, value]) => { + acc[key] = value.toString(); + return acc; + }, {}); + console.info('Sending Slack webhook:', JSON.stringify(stringifiedPayload)); + try { + const res = await fetch(webookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(stringifiedPayload), + }); + if (!res.ok) { + console.error('Error sending Slack webhook:', res.statusText); + summary.addSeparator(); + summary.addEOL().addRaw('> [!WARNING]').addEOL(); + summary + .addRaw('> An error occurred while sending the Slack webhook.') + .addEOL(); + if (res?.statusText.trim() !== '') { + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + summary.addEOL().addRaw(`> ${res.statusText}`).addEOL(); + summary.addEOL().addRaw(`> \`\`\``).addEOL(); + } + summary.write(); + } + else { + console.info('Slack webhook sent successfully.'); + } + } + catch (error) { + console.error('Error sending Slack webhook:', error); + summary.addSeparator(); + summary.addEOL().addRaw('> [!WARNING]').addEOL(); + summary + .addRaw('> An error occurred while sending the Slack webhook.') + .addEOL(); + summary + .addRaw(`> Error Message: \`${error.toString()}\``) + .addEOL(); + summary.write(); + } +} +function getGithubActionURL() { + const runId = process.env.GITHUB_RUN_ID; + const repo = process.env.GITHUB_REPOSITORY; + if (!runId || !repo) { + return 'https://github.com'; + } + return `https://github.com/${repo}/actions/runs/${runId}`; +} diff --git a/dist/workflow/summary.js b/dist/workflow/summary.js new file mode 100644 index 0000000..3bb0631 --- /dev/null +++ b/dist/workflow/summary.js @@ -0,0 +1,155 @@ +import { EOL } from 'os'; +import { constants, promises } from 'fs'; +const { access, appendFile, writeFile } = promises; +export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY'; +class Summary { + _buffer; + _filePath; + constructor() { + this._buffer = ''; + } + async filePath() { + if (this._filePath) { + return this._filePath; + } + const pathFromEnv = process.env[SUMMARY_ENV_VAR]; + if (!pathFromEnv) { + this._filePath = null; + return this._filePath; + } + try { + await access(pathFromEnv, constants.R_OK | constants.W_OK); + } + catch { + throw new Error(`Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.`); + } + this._filePath = pathFromEnv; + return this._filePath; + } + wrap(tag, content, attrs = {}) { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join(''); + if (!content) { + return `<${tag}${htmlAttrs}>`; + } + return `<${tag}${htmlAttrs}>${content}`; + } + async write(options) { + const overwrite = !!options?.overwrite; + const filePath = await this.filePath(); + if (!filePath) { + console.log(`~~~ SUMMARY ~~~${EOL}${this._buffer}${EOL}~~~ END SUMMARY ~~~`); + return this.emptyBuffer(); + } + const writeFunc = overwrite ? writeFile : appendFile; + await writeFunc(filePath, this._buffer, { encoding: 'utf8' }); + return this.emptyBuffer(); + } + async clear() { + return this.emptyBuffer().write({ overwrite: true }); + } + stringify() { + return this._buffer; + } + isEmptyBuffer() { + return this._buffer.length === 0; + } + emptyBuffer() { + this._buffer = ''; + return this; + } + addRaw(text, addEOL = false) { + this._buffer += text; + return addEOL ? this.addEOL() : this; + } + addEOL() { + return this.addRaw(EOL); + } + addCodeBlock(code, lang) { + const attrs = { + ...(lang && { lang }), + }; + const element = this.wrap('pre', this.wrap('code', code), attrs); + return this.addRaw(element).addEOL(); + } + addList(items, ordered = false) { + const tag = ordered ? 'ol' : 'ul'; + const listItems = items.map((item) => this.wrap('li', item)).join(''); + const element = this.wrap(tag, listItems); + return this.addRaw(element).addEOL(); + } + addTable(rows) { + const tableBody = rows + .map((row) => { + const cells = row + .map((cell) => { + if (typeof cell === 'string') { + return this.wrap('td', cell); + } + const { header, data, colspan, rowspan } = cell; + const tag = header ? 'th' : 'td'; + const attrs = { + ...(colspan && { colspan }), + ...(rowspan && { rowspan }), + }; + return this.wrap(tag, data, attrs); + }) + .join(''); + return this.wrap('tr', cells); + }) + .join(''); + const element = this.wrap('table', tableBody); + return this.addRaw(element).addEOL(); + } + addDetails(label, content) { + const element = this.wrap('details', this.wrap('summary', label) + content); + return this.addRaw(element).addEOL(); + } + addImage(src, alt, options) { + const { width, height } = options || {}; + const attrs = { + ...(width && { width }), + ...(height && { height }), + }; + const element = this.wrap('img', null, { src, alt, ...attrs }); + return this.addRaw(element).addEOL(); + } + addHeading(text, level) { + const tag = `h${level}`; + const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) + ? tag + : 'h1'; + const element = this.wrap(allowedTag, text); + return this.addRaw(element).addEOL(); + } + addSeparator() { + const element = this.wrap('hr', null); + return this.addRaw(element).addEOL(); + } + addBreak() { + const element = this.wrap('br', null); + return this.addRaw(element).addEOL(); + } + addQuote(text, cite) { + const attrs = { + ...(cite && { cite }), + }; + const element = this.wrap('blockquote', text, attrs); + return this.addRaw(element).addEOL(); + } + addLink(text, href) { + const element = this.wrap('a', text, { href }); + return this.addRaw(element).addEOL(); + } + addAlert(type, text) { + const element = text + .split(EOL) + .map((line) => `> ${line}`) + .join(EOL); + const alert = `> [!${type.toUpperCase()}]${EOL}${element}`; + return this.addRaw(alert).addEOL(); + } +} +const _summary = new Summary(); +export const summary = _summary; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2f25d03 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2700 @@ +{ + "name": "variable-update", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "variable-update", + "version": "1.0.0", + "license": "MPL-2.0", + "dependencies": { + "tinycolor2": "^1.6.0", + "yaml": "^2.5.1" + }, + "devDependencies": { + "@figma/rest-api-spec": "^0.19.0", + "@prettier/plugin-xml": "^3.4.1", + "@types/node": "^22.7.5", + "@types/tinycolor2": "^1.4.6", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "copyfiles": "^2.4.1", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "tsx": "^4.19.1", + "typescript": "5.6.3" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@figma/rest-api-spec": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@figma/rest-api-spec/-/rest-api-spec-0.19.0.tgz", + "integrity": "sha512-ixvPBugjBjgZIPP9QnhTzeWextGr4KQaHbB678OS6n3XUrmyLNzEdxuZfUfRVn0hX/VIOKWQcurYhyowjrb+yQ==", + "dev": true, + "license": "MIT License" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@prettier/plugin-xml": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-3.4.1.tgz", + "integrity": "sha512-Uf/6/+9ez6z/IvZErgobZ2G9n1ybxF5BhCd7eMcKqfoWuOzzNUxBipNo3QAP8kRC1VD18TIo84no7LhqtyDcTg==", + "dev": true, + "dependencies": { + "@xml-tools/parser": "^1.0.11" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.1.tgz", + "integrity": "sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.7.1", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/typescript-estree": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz", + "integrity": "sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.1.tgz", + "integrity": "sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==", + "dev": true, + "peer": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz", + "integrity": "sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz", + "integrity": "sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/types": "7.7.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@xml-tools/parser": { + "version": "1.0.11", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chevrotain": "7.1.1" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "7.1.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/copyfiles": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.0.5", + "minimatch": "^3.0.3", + "mkdirp": "^1.0.4", + "noms": "0.0.0", + "through2": "^2.0.1", + "untildify": "^4.0.0", + "yargs": "^16.1.0" + }, + "bin": { + "copyfiles": "copyfiles", + "copyup": "copyfiles" + } + }, + "node_modules/copyfiles/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/copyfiles/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.16.0", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "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/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", + "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/noms": { + "version": "0.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "~1.0.31" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "1.0.34", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsx": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz", + "integrity": "sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/untildify": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..10739b2 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "figma-variable-import", + "version": "1.0.0", + "type": "module", + "description": "This script updates the variables in a Figma file from the Firefox Design System", + "scripts": { + "start": "tsx ./src/index.ts", + "copy": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist", + "build": "tsc && npm run copy", + "lint": "eslint -c .eslintrc.cjs src/", + "prettier": "prettier -c .prettierrc.cjs --write --check 'src/**/*.*'" + }, + "license": "MPL-2.0", + "devDependencies": { + "@figma/rest-api-spec": "^0.19.0", + "@prettier/plugin-xml": "^3.4.1", + "@types/node": "^22.7.5", + "@types/tinycolor2": "^1.4.6", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "copyfiles": "^2.4.1", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "tsx": "^4.19.1", + "typescript": "5.6.3" + }, + "dependencies": { + "tinycolor2": "^1.6.0", + "yaml": "^2.5.1" + } +} diff --git a/src/Config.ts b/src/Config.ts new file mode 100644 index 0000000..113e891 --- /dev/null +++ b/src/Config.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'fs' +import YAML from 'yaml' + +const FIGMA_URL_REGEX = + /https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/([\w-]+)\/([0-9a-zA-Z]{22,128}))?(?:\/.*)?$/ + +class Config { + public readonly figmaFileId: string + public readonly centralCurrentColorAlias: string + public readonly centralSource: string + public readonly centralOverrides: { [key: string]: string } + public readonly figmaOnlyVariables: string[] | undefined + public readonly figmaAccessToken: string + public readonly slackWebhookUrlSuccess: string | undefined + public readonly slackWebhookUrlFailure: string | undefined + public readonly dryRun: boolean + + constructor() { + const config = YAML.parse(readFileSync('./config/config.yaml', 'utf8')) + + if (!config.env) { + config.env = {} + } + + this.figmaFileId = this.parseFigmaUrl( + config.env.FIGMA_URL || process.env.INPUT_FIGMA_URL, + ) + this.centralCurrentColorAlias = config.centralCurrentColorAlias + this.centralSource = config.centralSource + this.centralOverrides = config.centralOverrides + this.figmaOnlyVariables = config.figmaOnlyVariables + + // Environment variables (can be overriden by config.yaml) + this.figmaAccessToken = + config.env.FIGMA_ACCESS_TOKEN || process.env.INPUT_FIGMA_ACCESS_TOKEN + this.slackWebhookUrlSuccess = + config.env.SLACK_WEBHOOK_SUCCESS || + process.env.INPUT_SLACK_WEBHOOK_SUCCESS + this.slackWebhookUrlFailure = + config.env.SLACK_WEBHOOK_FAILURE || + process.env.INPUT_SLACK_WEBHOOK_FAILURE + this.dryRun = + config.env.DRY_RUN === 'true' || + process.env.INPUT_DRY_RUN === 'true' || + false + + this.testConfig() + } + + private parseFigmaUrl(figmaURL: string | undefined) { + if (!figmaURL || figmaURL === '') { + throw new Error('Error loading config: FIGMA_URL is undefined') + } + const match = figmaURL.match(FIGMA_URL_REGEX) + if (!match) { + throw new Error( + 'Error loading config: FIGMA_URL is not a valid Figma URL', + ) + } + if (match[1] !== 'design') { + throw new Error( + `Error loading config: FIGMA_URL is not a design URL, it is ${match[1]}`, + ) + } + // if match[3] === 'branch', then we have a branch URL and can replace figmaFileId with match[4] + if (match[3] && match[4] && match[3] === 'branch') { + return match[4] + } else { + return match[2] + } + } + + public potentiallyOverride(tokenName: string, tokenMode?: string): string { + const searchKey = tokenMode ? `${tokenName}#${tokenMode}` : tokenName + return this.centralOverrides[searchKey] + } + + private testConfig() { + if (this.figmaFileId === undefined || this.figmaFileId === '') { + throw new Error('Error loading config: figmaFileId is undefined') + } + if (this.centralCurrentColorAlias === undefined) { + throw new Error( + 'Error loading config: centralCurrentColorAlias is undefined', + ) + } + if (this.centralSource === undefined) { + throw new Error('Error loading config: centralSource is undefined') + } + if (this.centralOverrides === undefined) { + throw new Error('Error loading config: centralOverrides is undefined') + } else { + if (typeof this.centralOverrides !== 'object') { + throw new Error( + 'Error loading config: centralOverrides is not an object', + ) + } + if ( + !Object.keys(this.centralOverrides).every((k) => typeof k === 'string') + ) { + throw new Error( + 'Error loading config: centralOverrides keys are not strings', + ) + } + if ( + !Object.values(this.centralOverrides).every( + (v) => typeof v === 'string', + ) + ) { + throw new Error( + 'Error loading config: centralOverrides values are not strings', + ) + } + } + + if (this.figmaOnlyVariables !== undefined) { + if (!Array.isArray(this.figmaOnlyVariables)) { + throw new Error( + 'Error loading config: figmaOnlyVariables is not an array', + ) + } + if (!this.figmaOnlyVariables.every((v) => typeof v === 'string')) { + throw new Error( + 'Error loading config: figmaOnlyVariables is not an array of strings', + ) + } + } + + if (this.figmaAccessToken === undefined) { + throw new Error('Error loading config: figmaAccessToken is undefined') + } + } +} + +const configInstance = new Config() +export default configInstance diff --git a/src/UpdateConstructor.ts b/src/UpdateConstructor.ts new file mode 100644 index 0000000..1e2bd54 --- /dev/null +++ b/src/UpdateConstructor.ts @@ -0,0 +1,419 @@ +import { + ErrorResponsePayloadWithErrorBoolean, + LocalVariable, + PostVariablesRequestBody, + PostVariablesResponse, + RGBA, + VariableCreate, + VariableModeCreate, + VariableModeValue, + VariableUpdate, +} from '@figma/rest-api-spec' +import { + CentralCollections, + FigmaCollections, + FigmaVariableValue, + TypedCentralCollections, + TypedCentralVariable, +} from './types.js' +import { + FigmaAPIURLs, + SYMBOL_RESOLVED_TYPE, + determineResolvedTypeWithAlias, + extractAliasParts, + fetchFigmaAPI, +} from './utils.js' + +export type ExtraStats = { + variablesDeprecated: { collection: string; variable: string }[] + variablesUndeprecated: { collection: string; variable: string }[] + modesCreated: { collection: string; mode: string }[] + variablesCreated: { + collection: string + variable: string + resolvedType: VariableCreate['resolvedType'] + }[] + variableValuesUpdated: { + collection: string + variable: string + mode: string + oldValue: FigmaVariableValue + newValue: FigmaVariableValue + resolvedType: VariableCreate['resolvedType'] + }[] + result?: PostVariablesResponse | ErrorResponsePayloadWithErrorBoolean | string +} + +/** + * This class is used to keep track of changes that need to be submitted to the Figma API. + */ +class UpdateConstructor { + private idCounter: number + private changes: Required + private extraStats: ExtraStats + centralTokens: TypedCentralCollections + figmaTokens: FigmaCollections + + constructor( + centralTokens: CentralCollections, + figmaTokens: FigmaCollections, + ) { + this.centralTokens = inferResolvedTypes(centralTokens) + this.figmaTokens = figmaTokens + this.idCounter = 0 + this.changes = { + variableCollections: [], + variableModes: [], + variables: [], + variableModeValues: [], + } + this.extraStats = { + variablesDeprecated: [], + variablesUndeprecated: [], + modesCreated: [], + variablesCreated: [], + variableValuesUpdated: [], + } + } + + getChanges() { + return this.changes + } + getStats() { + return this.extraStats + } + + hasChanges() { + return Object.keys(this.changes).some( + (key) => this.changes[key as keyof typeof this.changes].length > 0, + ) + } + + getTempId() { + return `tempId${this.idCounter++}` + } + + async submitChanges(fileId: string) { + const changes = Object.fromEntries( + Object.entries(this.changes).filter(([, value]) => value.length > 0), + ) + + if (Object.keys(changes).length === 0) { + console.info('No changes to submit') + return + } + + console.info('Submitting changes:', changes) + fetchFigmaAPI(FigmaAPIURLs.postVariables(fileId), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(changes), + }) + .then((result) => { + this.extraStats.result = result + }) + .catch((error) => { + this.extraStats.result = `${error.message}\n\n${error.stack}` + }) + } + + createVariable( + name: string, + collectionLabel: string, + resolvedType: VariableCreate['resolvedType'], + ) { + const variableCollectionId = this.figmaTokens[collectionLabel].collection.id + + const tempId = this.getTempId() + const obj: VariableCreate = { + action: 'CREATE', + name, + id: tempId, + variableCollectionId, + resolvedType: resolvedType, + } + this.changes.variables.push(obj) + // we will also add the variable to the figma tokens with a temp id + this.figmaTokens[collectionLabel].variables.push({ + id: tempId, + name, + key: '---', + variableCollectionId, + resolvedType: resolvedType, + valuesByMode: {}, + remote: false, + description: '', + hiddenFromPublishing: false, + scopes: [], + codeSyntax: {}, + }) + + // Stats + this.extraStats.variablesCreated.push({ + collection: collectionLabel, + variable: name, + resolvedType, + }) + } + + createVariableMode(name: string, collectionLabel: string) { + const tempId = this.getTempId() + const obj: VariableModeCreate = { + action: 'CREATE', + id: tempId, + name, + variableCollectionId: this.figmaTokens[collectionLabel].collection.id, + } + this.changes.variableModes.push(obj) + // we will also add the variable mode to the figma tokens with a temp id + this.figmaTokens[collectionLabel].collection.modes.push({ + modeId: tempId, + name, + }) + + // Stats + this.extraStats.modesCreated.push({ + collection: collectionLabel, + mode: name, + }) + } + + updateVariable(update: Omit) { + const fullUpdate: VariableUpdate = { + action: 'UPDATE', + ...update, + } + const existing = this.changes.variables.find((v) => v.id === fullUpdate.id) + if (existing) { + Object.assign(existing, fullUpdate) + } else { + this.changes.variables.push(fullUpdate) + } + } + + setVariableValue( + variableId: string, + modeId: string, + value: RGBA | boolean | number | string, + ) { + const obj: VariableModeValue = { + variableId, + modeId, + value, + } + this.changes.variableModeValues.push(obj) + + // Stats + const { + collectionName, + variableName, + modeName, + currentValue, + resolvedType, + } = this.reverseSearchVariableInfo(variableId, modeId) + + this.extraStats.variableValuesUpdated.push({ + collection: collectionName, + variable: variableName, + mode: modeName, + oldValue: currentValue, + newValue: value, + resolvedType, + }) + } + + private reverseSearchVariableInfo(variableId: string, modeId: string) { + let collectionName, + variableName, + modeName, + currentValue: FigmaVariableValue | null = null, + resolvedType: VariableCreate['resolvedType'] | null = null + for (const { collection, variables } of Object.values(this.figmaTokens)) { + const variable = variables.find((v) => v.id === variableId) + if (variable) { + collectionName = collection.name + variableName = variable.name + modeName = collection.modes.find((m) => m.modeId === modeId)?.name + currentValue = variable.valuesByMode[modeId] + resolvedType = variable.resolvedType + break + } + } + // if any of them are undefined, throw an error + if ( + !collectionName || + !variableName || + !modeName || + currentValue === null || + resolvedType === null + ) { + throw new Error( + `When updating variable values: Could not find the collection, variable or mode for variable id '${variableId}' and mode id '${modeId}'`, + ) + } + return { + collectionName, + variableName, + modeName, + currentValue, + resolvedType, + } + } + + setVariableAlias(variableId: string, modeId: string, aliasId: string) { + const obj: VariableModeValue = { + variableId, + modeId, + value: { type: 'VARIABLE_ALIAS', id: aliasId }, + } + this.changes.variableModeValues.push(obj) + + // Stats + const { + collectionName, + variableName, + modeName, + currentValue, + resolvedType, + } = this.reverseSearchVariableInfo(variableId, modeId) + + this.extraStats.variableValuesUpdated.push({ + collection: collectionName, + variable: variableName, + mode: modeName, + oldValue: currentValue, + newValue: { type: 'VARIABLE_ALIAS', id: aliasId }, + resolvedType, + }) + } + + // ------- + + addDeprecationStat( + collection: string, + variable: string, + deprecated: boolean, + ) { + if (deprecated) { + this.extraStats.variablesDeprecated.push({ collection, variable }) + } else { + this.extraStats.variablesUndeprecated.push({ collection, variable }) + } + } + + // ------- + + getModeId(collectionLabel: string, modeName: string) { + return this.figmaTokens[collectionLabel].collection.modes.find( + (m) => m.name === modeName, + )?.modeId + } + + getVariable(collectionLabel: string, variableName: string) { + return this.figmaTokens[collectionLabel].variables.find( + (v) => v.name === variableName, + ) + } + + resolveCentralAlias(centralAlias: string): LocalVariable { + const aliasParts = extractAliasParts(centralAlias) + if (!aliasParts) { + throw new Error( + `When resolving alias '${centralAlias}', the alias could not be parsed`, + ) + } + const variable = this.figmaTokens[aliasParts.collection].variables.find( + (v) => v.name === aliasParts.variable, + ) + if (!variable) { + throw new Error( + `When resolving alias '${centralAlias}', the alias could not be found in the figma tokens`, + ) + } + return variable + } +} + +export default UpdateConstructor + +// function that converts CentralCollections to TypedCentralCollections +function inferResolvedTypes( + centralTokens: CentralCollections, +): TypedCentralCollections { + const typedCentralTokens: TypedCentralCollections = {} + const queue: Array<{ collectionName: string; variableName: string }> = [] + + const resolveVariableTypes = ( + collectionName: string, + variableName: string, + addToQueue: boolean, + ) => { + const variable = centralTokens[collectionName][variableName] + let lastResolvedType: VariableCreate['resolvedType'] | undefined = undefined + + for (const mode in variable) { + const value = variable[mode] + const resolvedType = determineResolvedTypeWithAlias( + typedCentralTokens, + value, + ) + if (resolvedType === null) { + if (addToQueue) { + queue.push({ collectionName, variableName }) + return + } else { + throw new Error( + `When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' could not be resolved (variable value: ${value})`, + ) + } + } + if (lastResolvedType && lastResolvedType !== resolvedType) { + throw new Error( + `When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' has conflicting types in different modes (${lastResolvedType} and ${resolvedType})`, + ) + } + lastResolvedType = resolvedType + } + + if (!lastResolvedType) { + throw new Error( + `When trying to infer variable types: Variable '${variableName}' in collection '${collectionName}' has no modes`, + ) + } + + const typedVariable: TypedCentralVariable = { + ...variable, + [SYMBOL_RESOLVED_TYPE]: lastResolvedType, + } + + if (!typedCentralTokens[collectionName]) { + typedCentralTokens[collectionName] = {} + } + + typedCentralTokens[collectionName][variableName] = typedVariable + } + + // We go through all the collections + for (const collectionName in centralTokens) { + const collection = centralTokens[collectionName] + for (const variableName in collection) { + resolveVariableTypes(collectionName, variableName, true) + } + } + // We'll try to resolve the variables that we couldn't resolve before. + // If they can't be resolved this time, we'll throw an error. + for (const { collectionName, variableName } of queue) { + resolveVariableTypes(collectionName, variableName, false) + } + + if (queue.length > 0) { + console.warn(`WARNING: ${queue.length} variables had to be resolved in a second pass. + This happens when an alias references a variable that is defined later in the central tokens. + While this is not a problem, you might be able to optimize the order of the central tokens. + If it is not possible to optimize the order anymore, you can remove this warning!`) + } + + return typedCentralTokens +} diff --git a/src/action.yaml b/src/action.yaml new file mode 100644 index 0000000..7e73e34 --- /dev/null +++ b/src/action.yaml @@ -0,0 +1,21 @@ +name: 'Central>Figma Variable Import' +description: 'Imports variables from Mozilla central to Figma' + +inputs: + FIGMA_URL: + description: 'URL of the file to import and update variables' + required: true + FIGMA_ACCESS_TOKEN: + description: 'Access token to authenticate with Figma' + required: true + SLACK_WEBHOOK_SUCCESS: + description: 'Webhook URL of a Slack workflow to notify on success' + SLACK_WEBHOOK_FAILURE: + description: 'Webhook URL of a Slack workflow to notify on failure' + DRY_RUN: + description: 'Whether to run the action without making changes' + default: 'true' + +runs: + using: 'node20' + main: 'index.js' diff --git a/src/central-import.ts b/src/central-import.ts new file mode 100644 index 0000000..4aaba39 --- /dev/null +++ b/src/central-import.ts @@ -0,0 +1,206 @@ +import Config from './Config.js' +import tinycolor from 'tinycolor2' + +type RawThemeTokens = { light: string; dark: string; forcedColors: string } +type RawCentralTokens = { + [key: string]: string | RawThemeTokens +} + +type ThemeTokens = { Light: string; Dark: string; HCM: string } +type PrimitiveTokens = { Value: string | number | boolean } +type SeparatedTokens = { + Theme: { [key: string]: ThemeTokens } + Primitives: { [key: string]: PrimitiveTokens } +} + +export async function getCentralCollectionValues() { + return downloadFromCentral() + .then(separateCentralTokens) + .then(replaceTextColor) + .then(replaceVariableReferences) +} + +/** + * Downloads data from the central source specified in the configuration. + * + * @returns {Promise} A promise that resolves to the raw central tokens. + */ +async function downloadFromCentral() { + return (await fetch(Config.centralSource).then((res) => + res.json(), + )) as RawCentralTokens +} + +/** + * Separates raw central tokens into seperate theme and primitive objects. + * Both get normalized to the mode names expected by Figma. + * Also applies any value overrides specified in the configuration. + * + * @param rawCentralTokens - The raw central tokens to be separated. + * @returns An object containing separated tokens categorized into primitives and theme tokens. + * + * @throws Will throw an error if the value type of a token is unknown. + */ +function separateCentralTokens( + rawCentralTokens: RawCentralTokens, +): SeparatedTokens { + return Object.entries(rawCentralTokens).reduce( + (acc, [key, value]) => { + if (typeof value === 'string') { + acc.Primitives[key] = { + Value: Config.potentiallyOverride(key) || value, + } + } else if ( + 'light' in value && + 'dark' in value && + 'forcedColors' in value + ) { + acc.Theme[key] = { + Light: Config.potentiallyOverride(key, 'light') || value.light, + Dark: Config.potentiallyOverride(key, 'dark') || value.dark, + HCM: + Config.potentiallyOverride(key, 'forcedColors') || + value.forcedColors, + } + } else { + throw new Error( + `When separating central tokens, the value type of token '${key}' is unknown: ${JSON.stringify(value)}`, + ) + } + + return acc + }, + { Primitives: {}, Theme: {} } as SeparatedTokens, + ) +} + +// TODO: Function that replaces text color +function replaceTextColor(tokens: SeparatedTokens): SeparatedTokens { + const colorMixTf = new ColorMix(tokens.Theme, Config.centralCurrentColorAlias) + for (const [key, value] of Object.entries(tokens.Theme)) { + for (const mode of ['Light', 'Dark'] as const) { + const color = value[mode] + if (colorMixTf.isColorMix(color)) { + tokens.Theme[key][mode] = colorMixTf.replaceColorMix(mode, color) + } + } + } + return tokens +} + +/** + * Replaces variable references in the provided tokens object. + * + * This function iterates over all theme tokens and replaces color values with corresponding aliases. + * For Light and Dark modes, it looks for a corresponding color in the primitives and replaces the color + * with an alias in the format `{Primitives$path/to/color}`. For HCM mode, it replaces the name with + * `{HCM Theme$hcmtoken}`. + * + * To optimize performance, a map of all primitive colors is created initially. + * + * @param tokens - The tokens object containing theme and primitive color definitions. + * @returns The modified tokens object with replaced variable references. + */ +function replaceVariableReferences(tokens: SeparatedTokens): SeparatedTokens { + const primitiveLookupMap = new Map() + for (const [key, value] of Object.entries(tokens.Primitives)) { + const tinyCurrentColor = tinycolor(value.Value as string) + // skip if it does not contain a color + if (!tinyCurrentColor.isValid()) { + continue + } + primitiveLookupMap.set(tinyCurrentColor.toHex8String(), key) + } + + for (const [key, value] of Object.entries(tokens.Theme)) { + for (const mode of ['Light', 'Dark', 'HCM'] as const) { + const color = value[mode] + if (mode === 'HCM') { + tokens.Theme[key][mode] = `{HCM Theme$${color}}` + } else { + const tinyCurrentColor = tinycolor(color) + // we only do this for colors, under the assumptions that colors are unique + if (!tinyCurrentColor.isValid()) { + continue + } + // look up the color in the map + const refVariable = primitiveLookupMap.get( + tinyCurrentColor.toHex8String(), + ) + if (refVariable) { + tokens.Theme[key][mode] = `{Primitives$${refVariable}}` + } + } + } + } + + return tokens +} + +const COLOR_MIX_REGEX = + /color-mix\(in srgb, currentColor (\d+)%?, transparent\)/ + +/** + * Class to replace color-mix functions with an actual color based on the mode. + */ +class ColorMix { + light: tinycolor.Instance + dark: tinycolor.Instance + + /** + * Creates a new instance of the ColorMix class. + * @param collection - The collection of colors. + * @param key - The key to access the colors in the collection. + * @throws Error if the light or dark color is invalid. + */ + constructor(collection: SeparatedTokens['Theme'], key: string) { + const colors = collection[key] + this.light = tinycolor(colors.Light) + this.dark = tinycolor(colors.Dark) + + if (!this.light.isValid()) { + throw new Error( + `When initializing ColorMix, the light color is invalid: ${colors.Light}`, + ) + } + if (!this.dark.isValid()) { + throw new Error( + `When initializing ColorMix, the dark color is invalid: ${colors.Dark}`, + ) + } + } + + /** + * Checks if a string represents a color mix. + * @param str - The string to check. + * @returns True if the string represents a color mix, false otherwise. + */ + isColorMix(str: string) { + return COLOR_MIX_REGEX.test(str) + } + + /** + * Replaces a color mix with a new color based on the mode. + * @param mode - The mode ('Light' or 'Dark') to determine which color to use. + * @param str - The string representing the color mix. + * @returns The new color as a hex8 string. + * @throws Error if the color mix is invalid. + */ + replaceColorMix(mode: 'Light' | 'Dark', str: string) { + const match = str.match(COLOR_MIX_REGEX) + + if (!match) { + throw new Error( + `When replacing color mix, the color mix is invalid: ${str}`, + ) + } + + const percentage = parseInt(match[1]) + const newColor = this[mode === 'Light' ? 'light' : 'dark'] + .clone() + .setAlpha(percentage / 100) + .toHex8String() + + return newColor + } +} diff --git a/src/imports.ts b/src/imports.ts new file mode 100644 index 0000000..79db3e8 --- /dev/null +++ b/src/imports.ts @@ -0,0 +1,13 @@ +import { CentralCollection } from './types.js' +import { readFileSync } from 'fs' +import YAML from 'yaml' + +export const HCM_MAP: CentralCollection = YAML.parse( + readFileSync('./config/hcm.yaml', 'utf8'), +) +export const OPERATING_SYSTEM_MAP: CentralCollection = YAML.parse( + readFileSync('./config/operating-system.yaml', 'utf8'), +) +export const SURFACE_MAP: CentralCollection = YAML.parse( + readFileSync('./config/surface.yaml', 'utf8'), +) diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..07691d7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,73 @@ +import { GetLocalVariablesResponse } from '@figma/rest-api-spec' +import Config from './Config.js' +import { getCentralCollectionValues } from './central-import.js' +import { CentralCollections, FigmaCollections } from './types.js' +import { fetchFigmaAPI, FigmaAPIURLs } from './utils.js' +import { HCM_MAP, OPERATING_SYSTEM_MAP, SURFACE_MAP } from './imports.js' +import UpdateConstructor from './UpdateConstructor.js' +import { addModesDefinitions } from './transform/modeDefinitions.js' +import { updateVariableDefinitions } from './transform/variableDefinitions.js' +import { updateVariables } from './transform/updateVariables.js' +import { documentError, documentStats } from './workflow/index.js' + +async function run() { + const { meta: figmaData } = await fetchFigmaAPI( + FigmaAPIURLs.getVariables(Config.figmaFileId), + ) + + const centralTokens: CentralCollections = { + 'HCM Theme': HCM_MAP, + 'Operating System': OPERATING_SYSTEM_MAP, + ...(await getCentralCollectionValues()), + Surface: SURFACE_MAP, + } + + // normalizeCentralTokens(await getCentralTokens()) + const figmaTokens = normalizeFigmaTokens(figmaData) + + // // STEP 1: Create a new UpdateConstructor instance to keep track of changes + const uc = new UpdateConstructor(centralTokens, figmaTokens) + + // // STEP 2: Iterate over collections and add missing modes + addModesDefinitions(uc) + + // // STEP 3: Iterate over collections and add missing variables + updateVariableDefinitions(uc) + + // // STEP 4: Update the values of the variables + updateVariables(uc) + + if (!Config.dryRun) { + uc.submitChanges(Config.figmaFileId) + } + + documentStats(uc.getStats()) +} + +run().catch((error) => { + documentError(error as Error).then(() => { + throw error + }) +}) + +function normalizeFigmaTokens( + figmaData: GetLocalVariablesResponse['meta'], +): FigmaCollections { + return Object.keys(figmaData.variableCollections).reduce((acc, key) => { + const collection = figmaData.variableCollections[key] + if (!collection) { + throw new Error( + `When normalizing Figma tokens, the collection '${key}' was not found`, + ) + } + const variables = Object.values(figmaData.variables).filter( + (v) => v.variableCollectionId === collection.id, + ) + + acc[collection.name] = { + collection, + variables, + } + return acc + }, {} as FigmaCollections) +} diff --git a/src/transform/modeDefinitions.ts b/src/transform/modeDefinitions.ts new file mode 100644 index 0000000..acbd594 --- /dev/null +++ b/src/transform/modeDefinitions.ts @@ -0,0 +1,50 @@ +import UpdateConstructor from '../UpdateConstructor.js' +import { FigmaCollection, TypedCentralCollection } from '../types.js' + +/** + * Adds mode definitions to the given `UpdateConstructor` instance. + * + * @param uc - The `UpdateConstructor` instance to add mode definitions to. + */ +export function addModesDefinitions(uc: UpdateConstructor) { + for (const collectionLabel in uc.centralTokens) { + // Throw an error if the collection is missing in the figma file. + if (!uc.figmaTokens[collectionLabel]) { + throw new Error( + `The collection '${collectionLabel}' is missing in the figma file. Please add it to the figma file before running the script again. +Figma collections: ${Object.keys(uc.figmaTokens).join(', ')} +Central collections: ${Object.keys(uc.centralTokens).join(', ')}`, + ) + } + // Generate a set of modes only present in the central collection. + const { onlyInCentral } = generateModeSets( + uc.centralTokens[collectionLabel], + uc.figmaTokens[collectionLabel], + ) + // Create a variable mode for each mode only present in the central collection. + for (const key of onlyInCentral) { + uc.createVariableMode(key, collectionLabel) + } + } +} + +/** + * Generates a set of modes only present in the central collection. + * @param {CentralCollecion} central - The central collection. + * @param {FigmaCollection} figma - The figma collection. + * @returns {Object} - An object containing the modes only present in the central collection. + */ +function generateModeSets( + central: TypedCentralCollection, + figma: FigmaCollection, +): { onlyInCentral: Set } { + const figmaModes = new Set(figma.collection.modes.map((m) => m.name)) + const centralKeys = new Set(Object.keys(central[Object.keys(central)[0]])) + + const onlyInCentral = new Set( + [...centralKeys].filter((key) => !figmaModes.has(key)), + ) + return { + onlyInCentral, + } +} diff --git a/src/transform/updateVariables.ts b/src/transform/updateVariables.ts new file mode 100644 index 0000000..a58ff3e --- /dev/null +++ b/src/transform/updateVariables.ts @@ -0,0 +1,181 @@ +import { RGBA, VariableAlias } from '@figma/rest-api-spec' +import tinycolor from 'tinycolor2' +import UpdateConstructor from '../UpdateConstructor.js' +import { + isFigmaAlias, + normalizeRGBA, + denormalizeRGBA, + SYMBOL_RESOLVED_TYPE, + isCentralAlias, +} from '../utils.js' +import { FigmaVariableData, TypedCentralVariable } from '../types.js' + +/** + * Updates the variable values if central values don't match the Figma values. + * + * @param uc - An UpdateConstructor instance. + */ +export function updateVariables(uc: UpdateConstructor) { + for (const collectionName in uc.centralTokens) { + // iterate over all values in the current collection + for (const [variableName, centralValues] of Object.entries( + uc.centralTokens[collectionName], + )) { + // iterate over keys in centralValues + for (const [modeName, centralValue] of Object.entries(centralValues)) { + // get the figma mode id based on the key and collection + const figmaVariableData = getFigmaVariableData( + uc, + collectionName, + modeName, + variableName, + ) + + // check if the values in figma and central are the same already + const requiresUpdate = checkIfUpdateRequired( + figmaVariableData, + centralValue, + uc, + centralValues, + ) + + // if no update is required, we can continue to the next variable + if (!requiresUpdate) { + continue + } + + // TYPE 1: The central value is an alias + if (isCentralAlias(centralValue)) { + const resolvedAlias = uc.resolveCentralAlias(centralValue as string) + if (!resolvedAlias) + throw new Error( + `When resolving alias '${centralValue}' in collection '${collectionName}', the alias could not be found`, + ) + uc.setVariableAlias( + figmaVariableData.info.id, + figmaVariableData.modeId, + resolvedAlias.id, + ) + continue + } + + // TYPE 2: The figma value is a color + if (centralValues[SYMBOL_RESOLVED_TYPE] === 'COLOR') { + // for a color we need to convert to a tinycolor and then to RGBA + // convert the central value to a tinycolor instance + const centralTiny = tinycolor(centralValue as string) + // the central value one has to be valid, since its our source of truth + if (!centralTiny.isValid()) { + throw new Error( + `When updating variables: Invalid central color value: ${centralValue} for token ${variableName} in collection ${collectionName}`, + ) + } + // now we just set the value to the figma variable + uc.setVariableValue( + figmaVariableData.info.id, + figmaVariableData.modeId, + normalizeRGBA(centralTiny.toRgb()), + ) + continue + } + + // TYPE 3: The central value is a string, boolean or float + uc.setVariableValue( + figmaVariableData.info.id, + figmaVariableData.modeId, + centralValue, + ) + } + } + } +} + +function checkIfUpdateRequired( + figmaVariableData: FigmaVariableData, + centralValue: string | number | boolean, + uc: UpdateConstructor, + centralValues: TypedCentralVariable, +) { + let requiresUpdate = figmaVariableData.value === undefined + + // if either of them is a variable and the other is not, we need to update + if (!requiresUpdate) { + const isCentralValueAlias = isCentralAlias(centralValue) + const isFigmaValueAlias = isFigmaAlias(figmaVariableData.value) + if (isCentralValueAlias !== isFigmaValueAlias) { + requiresUpdate = true + + // if both are variables, we need to check if they are the same + } else if (isCentralValueAlias && isFigmaValueAlias) { + const resolveCentralAlias = uc.resolveCentralAlias(centralValue as string) + if ( + resolveCentralAlias.id !== (figmaVariableData.value as VariableAlias).id + ) { + requiresUpdate = true + } + } else if (centralValues[SYMBOL_RESOLVED_TYPE] === 'FLOAT') { + // Figma does some weird stuff with numbers, so we need to compare them as strings rounded to 4 decimal places + // The four is somewhat arbitrarily chosen, but it should be enough precision for most use cases + if ( + (centralValue as number).toFixed(4) !== + (figmaVariableData.value as number).toFixed(4) + ) { + requiresUpdate = true + } + } else if (centralValues[SYMBOL_RESOLVED_TYPE] !== 'COLOR') { + // if its' not a color or an alias it has to be a string, boolean or float, and we can just compare + if (figmaVariableData.value !== centralValue) { + requiresUpdate = true + } + } else { + // for colors, we convert both to tinycolor instances and compare + // if figmaVariableData.value is not an object that contains R, G, B, A, we already know it's not a color and it needs to be updated + if ( + !figmaVariableData.value || + typeof figmaVariableData.value !== 'object' || + !('r' in figmaVariableData.value) + ) { + requiresUpdate = true + } else { + const centralTiny = tinycolor(centralValue as string) + const figmaTiny = tinycolor( + denormalizeRGBA(figmaVariableData.value as RGBA), + ) + if (!figmaTiny.isValid() || !tinycolor.equals(centralTiny, figmaTiny)) { + requiresUpdate = true + } + } + } + } + return requiresUpdate +} + +/** + * Retrieves Figma variable data based on the provided parameters. + * + * @param uc - The UpdateConstructor instance. + * @param collectionName - The name of the collection. + * @param modeName - The name of the mode. + * @param variableName - The name of the variable. + * @returns An object containing the modeId, info, and value of the variable. + * @throws Error if the mode or variable is not found. + */ +function getFigmaVariableData( + uc: UpdateConstructor, + collectionName: string, + modeName: string, + variableName: string, +): FigmaVariableData { + const modeId = uc.getModeId(collectionName, modeName) + if (!modeId) + throw new Error( + `When updating variables: Mode ${modeName} not found in collection ${collectionName}`, + ) + const info = uc.getVariable(collectionName, variableName) + if (!info) + throw new Error( + `When updating variables: Variable ${variableName} not found in collection ${collectionName}`, + ) + const value = info.valuesByMode[modeId] + return { value, info, modeId } +} diff --git a/src/transform/variableDefinitions.ts b/src/transform/variableDefinitions.ts new file mode 100644 index 0000000..ecfd1b0 --- /dev/null +++ b/src/transform/variableDefinitions.ts @@ -0,0 +1,134 @@ +import UpdateConstructor from '../UpdateConstructor.js' +import { CentralCollection, FigmaCollection } from '../types.js' +import Config from '../Config.js' +import { SYMBOL_RESOLVED_TYPE } from '../utils.js' + +/** + * Updates the variable definitions based on the provided UpdateConstructor. + * This function compares the central tokens with the Figma tokens and performs + * the necessary updates to the variable definitions. + * + * @param uc - The UpdateConstructor object containing the central and Figma tokens. + */ +export function updateVariableDefinitions(uc: UpdateConstructor) { + for (const collectionLabel in uc.centralTokens) { + const sets = generateVariableSets( + uc.centralTokens[collectionLabel], + uc.figmaTokens[collectionLabel], + ) + // Create variables that are only in the central collection + for (const key of sets.onlyInCentral) { + // we need to determine the type of the variable + const resolvedType = + uc.centralTokens[collectionLabel][key][SYMBOL_RESOLVED_TYPE] + uc.createVariable(key, collectionLabel, resolvedType) + } + + // Add deprecation tags to variables that are only in the Figma collection + for (const key of sets.onlyInFigma) { + if (Config.figmaOnlyVariables?.includes(key)) { + continue + } + const variableData = uc.figmaTokens[collectionLabel].variables.find( + (v) => v.name === key, + ) + if (!variableData) { + throw new Error( + `When adding deprecation tags, the variable ${key} could not be found in the Figma tokens`, + ) + } + const newDescription = potentiallyAddDeprecated(variableData.description) + if (newDescription) { + uc.updateVariable({ + id: variableData.id, + description: newDescription, + }) + uc.addDeprecationStat(collectionLabel, variableData.id, true) + } + } + + // Remove deprecation tags from variables that are in both collections + for (const key of sets.inBoth) { + const variableData = uc.figmaTokens[collectionLabel].variables.find( + (v) => v.name === key, + ) + if (!variableData) { + throw new Error( + `When removing deprecation tags, the variable ${key} could not be found in the Figma tokens`, + ) + } + const newDescription = potentiallyRemoveDeprecated( + variableData.description, + ) + if (newDescription) { + uc.updateVariable({ + id: variableData.id, + description: newDescription, + }) + uc.addDeprecationStat(collectionLabel, variableData.id, false) + } + } + } +} + +/** + * Adds a deprecated tag to the description if it doesn't already have one. + * @param description - The original description. + * @returns The updated description with a deprecated tag, or undefined if the description already has a deprecated tag. + */ +function potentiallyAddDeprecated(description: string) { + // check if description already has a deprecated tag + if (description.includes('[deprecated]')) { + return undefined + } + return `${description.trimEnd()}\n\n[deprecated] This variable is deprecated.`.trimStart() +} + +/** + * Removes deprecated lines from the given description. + * If the description does not contain any deprecated lines, returns undefined. + * @param description - The description to process. + * @returns The processed description with deprecated lines removed, or undefined if no deprecated lines were found. + */ +function potentiallyRemoveDeprecated(description: string) { + if (!description.includes('[deprecated]')) { + return undefined + } + return description + .split('\n') + .filter((line) => !line.includes('[deprecated]')) + .join('\n') + .trimEnd() +} + +/** + * Generates sets of variables based on the comparison between the central collection and the Figma collection. + * @param {CentralCollecion} central - The central collection of variables. + * @param {FigmaCollection} figma - The Figma collection of variables. + * @returns {Object} - An object containing sets of variables that are only in the central collection, only in the Figma collection, and in both collections. + */ +function generateVariableSets( + central: CentralCollection, + figma: FigmaCollection, +): { + onlyInCentral: Set + onlyInFigma: Set + inBoth: Set +} { + const centralKeys = new Set(Object.keys(central)) + const figmaKeys = new Set(figma.variables.map((v) => v.name)) + + const onlyInCentral = new Set( + [...centralKeys].filter((key) => !figmaKeys.has(key)), + ) + const onlyInFigma = new Set( + [...figmaKeys].filter((key) => !centralKeys.has(key)), + ) + const inBoth = new Set([...centralKeys].filter((key) => figmaKeys.has(key))) + + return { + onlyInCentral, + onlyInFigma, + inBoth, + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..907594f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,84 @@ +import { + LocalVariable, + LocalVariableCollection, + RGBA, + VariableAlias, + VariableCreate, +} from '@figma/rest-api-spec' +import { SYMBOL_RESOLVED_TYPE } from './utils.js' + +// --- +// CENTRAL +// --- + +/** + * A variable is an object where each key is a mode and + * the value is the value of the variable in that mode + */ +export type CentralVariable = { + [key: string]: number | boolean | string +} +/** + * Extends the {@link CentralVariable} type by adding the resolved type + */ +export type TypedCentralVariable = { + [key: string]: number | boolean | string + [SYMBOL_RESOLVED_TYPE]: VariableCreate['resolvedType'] +} + +/** + * A collection is an object where each key is a variable name + * and the value is the variable object + */ +export type CentralCollection = { + [key: string]: CentralVariable +} + +/** + * Extends the {@link CentralCollection} type by adding the resolved type + */ +export type TypedCentralCollection = { + [key: string]: TypedCentralVariable +} + +/** + * An object where each key is a collection name and + * the value are the variables in that collection + */ +export type CentralCollections = { + [key: string]: CentralCollection +} + +/** + * An object where each key is a collection name and + * the value are the typed variables in that collection + */ +export type TypedCentralCollections = { + [key: string]: TypedCentralCollection +} + +// --- +// FIGMA +// --- + +export type FigmaCollection = { + collection: LocalVariableCollection + variables: LocalVariable[] +} + +export type FigmaCollections = { + [key: string]: FigmaCollection +} + +export type FigmaVariableData = { + modeId: string + info: LocalVariable + value?: FigmaVariableValue +} + +export type FigmaVariableValue = + | string + | number + | boolean + | RGBA + | VariableAlias diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..5d58862 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,145 @@ +import { RGBA, VariableAlias, VariableCreate } from '@figma/rest-api-spec' +import tinycolor from 'tinycolor2' +import { TypedCentralCollections } from './types.js' +import Config from './Config.js' + +const FIGMA_API_ENDPOINT = 'https://api.figma.com' + +export const FigmaAPIURLs = { + getVariables: (fileId: string) => + `${FIGMA_API_ENDPOINT}/v1/files/${fileId}/variables/local`, + postVariables: (fileId: string) => + `${FIGMA_API_ENDPOINT}/v1/files/${fileId}/variables`, +} + +export async function fetchFigmaAPI( + url: string, + options: RequestInit = {}, +): Promise { + const headers = { + 'X-FIGMA-TOKEN': Config.figmaAccessToken, + ...options.headers, + } + + const finalOptions: RequestInit = { + ...options, + headers, + } + + try { + const response = await fetch(url, finalOptions) + const data = await response.json() + if (data.error === true) { + throw new Error( + `When fetching Figma API, an error occurred: ${data.message}`, + ) + } + + return data as T + } catch (error) { + console.error('Error fetching Figma API:', error) + throw error + } +} + +export function roundTwoDecimals(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +export function isFigmaAlias( + value: string | number | boolean | RGBA | VariableAlias | undefined, +): value is VariableAlias { + return value !== undefined && typeof value === 'object' && 'type' in value +} + +// Figm expects values between 0 and 1 +export function normalizeRGBA(rgba: RGBA) { + return { + r: rgba.r / 255, + g: rgba.g / 255, + b: rgba.b / 255, + a: roundTwoDecimals(rgba.a), + } +} + +// tinycolors2 expects values between 0 and 255 +export function denormalizeRGBA(rgba: RGBA) { + return { + r: Math.floor(rgba.r * 255), + g: Math.floor(rgba.g * 255), + b: Math.floor(rgba.b * 255), + a: rgba.a, + } +} + +// create a symbol as they key for the resovled type +export const SYMBOL_RESOLVED_TYPE = Symbol('resolvedType') + +// 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' +export function determineResolvedType( + value: string | number | boolean, +): VariableCreate['resolvedType'] { + // check if it's a boolean + if (typeof value === 'boolean') { + return 'BOOLEAN' + } + // then check if it's a number + if (!isNaN(Number(value))) { + return 'FLOAT' + } + // then check if it's a color + if (tinycolor(value as string).isValid()) { + return 'COLOR' + } + // otherwise, check if its a string + if (typeof value === 'string') { + return 'STRING' + } + // if none of the above, throw an error + throw new Error(`Could not determine type for value: ${value}`) +} + +const ALIAS_REGEX = /{([^$]+)\$([^}]+)}/ + +export function isCentralAlias(value: string | number | boolean): boolean { + if (typeof value !== 'string') return false + return ALIAS_REGEX.test(value) +} + +export function extractAliasParts( + value: string | number | boolean, +): { collection: string; variable: string } | null { + if (typeof value !== 'string') return null + const match = ALIAS_REGEX.exec(value) + if (match) { + return { + collection: match[1], + variable: match[2], + } + } + return null +} + +export function determineResolvedTypeWithAlias( + collections: TypedCentralCollections, + value: string | number | boolean, +): VariableCreate['resolvedType'] | null { + const resolvedType = determineResolvedType(value) + if (resolvedType !== 'STRING') return resolvedType + + const aliasParts = extractAliasParts(value as string) + if (aliasParts) { + const { collection, variable } = aliasParts + if (collections[collection]?.[variable]) { + return collections[collection][variable][SYMBOL_RESOLVED_TYPE] + } + return null + } + + return resolvedType +} + +export function roundTo(value: number, decimals: number = 2): number { + const factor = Math.pow(10, decimals) + return Math.round((value + Number.EPSILON) * factor) / factor +} diff --git a/src/workflow/index.ts b/src/workflow/index.ts new file mode 100644 index 0000000..d61ca84 --- /dev/null +++ b/src/workflow/index.ts @@ -0,0 +1,350 @@ +import { VariableCreate } from '@figma/rest-api-spec' +import { FigmaVariableValue } from '../types.js' +import { ExtraStats } from '../UpdateConstructor.js' +import { denormalizeRGBA, isFigmaAlias, roundTo } from '../utils.js' +import tinycolor from 'tinycolor2' +import { summary } from './summary.js' +import Config from '../Config.js' + +type SlackWorkflowStats = Record, number> & { + actionURL: string +} +type SlackErrorPayload = { + errorMessage: string + actionURL: string +} + +export async function documentStats(stats: ExtraStats) { + setGithubWorkflowSummary(stats) + await sendSlackWorkflowStats(stats) +} + +export async function documentError(error: Error | string) { + setGithubWorkflowError(error) + await sendSlackWorkflowError(error) +} + +export async function sendSlackWorkflowStats(stats: ExtraStats): Promise { + if (!Config.slackWebhookUrlSuccess) return + const numberStats: Record, number> = { + modesCreated: stats.modesCreated.length, + variablesCreated: stats.variablesCreated.length, + variableValuesUpdated: stats.variableValuesUpdated.length, + variablesDeprecated: stats.variablesDeprecated.length, + variablesUndeprecated: stats.variablesUndeprecated.length, + } + + const total = Object.values(numberStats).reduce((acc, curr) => acc + curr, 0) + if (total === 0) return + + const payload: SlackWorkflowStats = { + ...numberStats, + actionURL: getGithubActionURL(), + } + + return sendSlackWebhook(Config.slackWebhookUrlSuccess, payload) +} + +export function setGithubWorkflowSummary(stats: ExtraStats) { + summary.addHeading('Central>Figma Variable Import Summary', 2) + + if (Config.dryRun) { + summary.addEOL().addRaw('> [!NOTE]').addEOL() + summary + .addRaw('> This was a dry run. The changes were not submitted to Figma.') + .addEOL() + } else if (stats.result === undefined) { + summary.addEOL().addRaw('> [!WARNING]').addEOL() + summary + .addRaw( + '> Changes were supposed to be submitted to Figma, but no result was recorded, which indicates a possible error.', + ) + .addEOL() + } else if (typeof stats.result === 'object' && 'error' in stats.result) { + if (stats.result.error === true) { + summary.addEOL().addRaw('> [!CAUTION]').addEOL() + summary + .addRaw( + `> An error occurred while submitting changes to Figma. (Status code: ${stats.result.status})`, + ) + .addEOL() + if (stats.result.message) { + summary.addEOL().addRaw(`>`).addEOL() + summary.addEOL().addRaw(`> \`\`\``).addEOL() + stats.result.message.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL() + }) + summary.addEOL().addRaw(`> \`\`\``).addEOL() + } + } else { + summary.addEOL().addRaw('> [!NOTE]').addEOL() + summary + .addRaw('> Changes were submitted to Figma without any errors.') + .addEOL() + } + } else { + summary.addEOL().addRaw('> [!CAUTION]').addEOL() + summary + .addRaw( + '> An unexpected error occurred while submitting changes to Figma.', + ) + .addEOL() + // if stats.result is a string, add it to the summary + if (typeof stats.result === 'string') { + summary.addEOL().addRaw(`>`).addEOL() + summary.addEOL().addRaw(`> \`\`\``).addEOL() + stats.result.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL() + }) + summary.addEOL().addRaw(`> \`\`\``).addEOL() + } + } + summary.addEOL() + + // Modes created + summary.addHeading('Modes created', 3) + if (stats.modesCreated.length === 0) { + const element = summary.wrap('p', 'No modes were created.') + summary.addEOL().addRaw(element).addEOL() + } else { + // create a table with the collection and mode name + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Mode created', header: true }, + ], + ...stats.modesCreated.map((mode) => [mode.collection, mode.mode]), + ]) + } + + // Variables created + summary.addHeading('Variables created', 3) + if (stats.variablesCreated.length === 0) { + const element = summary.wrap('p', 'No variables were created.') + summary.addEOL().addRaw(element).addEOL() + } else { + // create a table with the collection, variable name and resolved type + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + { data: 'Type', header: true }, + ], + ...stats.variablesCreated.map((variable) => [ + variable.collection, + summary.wrap('strong', variable.variable), + variable.resolvedType, + ]), + ]) + } + + // Variable values updated + summary.addHeading('Variable values updated', 3) + if (stats.variableValuesUpdated.length === 0) { + const element = summary.wrap('p', 'No variable values were updated.') + summary.addEOL().addRaw(element).addEOL() + } else { + // create a table with the collection, variable name, mode, old value and new value + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + { data: 'Mode', header: true }, + { data: 'Old value', header: true }, + { data: 'New value', header: true }, + ], + ...stats.variableValuesUpdated.map((variable) => [ + variable.collection, + summary.wrap('strong', variable.variable), + variable.mode, + variable.oldValue !== undefined + ? summary.wrap( + 'code', + formatFigmaVariableValue( + variable.oldValue, + variable.resolvedType, + ), + ) + : '', + summary.wrap( + 'code', + formatFigmaVariableValue(variable.newValue, variable.resolvedType), + ), + ]), + ]) + } + + // Variables deprecated + summary.addHeading('Variables deprecated', 3) + const element1 = summary.wrap( + 'p', + 'Variables where a deprecation warning was added to the description.', + ) + summary.addEOL().addRaw(element1).addEOL() + if (stats.variablesDeprecated.length === 0) { + const element = summary.wrap('p', 'No variables were deprecated.') + summary.addEOL().addRaw(element).addEOL() + } else { + // create a table with the collection and variable name + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + ], + ...stats.variablesDeprecated.map((variable) => [ + variable.collection, + variable.variable, + ]), + ]) + } + + // Variables undeprecated + summary.addHeading('Variables undeprecated', 3) + const element2 = summary.wrap( + 'p', + 'Variables where a deprecation warning was removed from the description.', + ) + summary.addEOL().addRaw(element2).addEOL + if (stats.variablesUndeprecated.length === 0) { + const element = summary.wrap('p', 'No variables were undeprecated.') + summary.addEOL().addRaw(element).addEOL() + } else { + // create a table with the collection and variable name + summary.addTable([ + [ + { data: 'Collection', header: true }, + { data: 'Variable', header: true }, + ], + ...stats.variablesUndeprecated.map((variable) => [ + variable.collection, + variable.variable, + ]), + ]) + } + + summary.write() +} + +function setGithubWorkflowError(error: string | Error) { + const errorMessage = + typeof error === 'string' + ? error + : error.stack || error.message || 'An unknown error occurred.' + + summary.addHeading('Central>Figma Variable Import Summary', 2) + summary.addEOL().addRaw('> [!CAUTION]').addEOL() + summary.addEOL().addRaw('> An error occurred while running the script.').addEOL() + summary.addEOL().addRaw(`>`).addEOL() + summary.addEOL().addRaw(`> \`\`\``).addEOL() + errorMessage.split('\n').forEach((line) => { + summary.addEOL().addRaw(`> ${line}`).addEOL() + }) + summary.addEOL().addRaw(`> \`\`\``).addEOL() + summary.write() +} + +async function sendSlackWorkflowError(error: string | Error): Promise { + if (!Config.slackWebhookUrlFailure) return + + const payload: SlackErrorPayload = { + errorMessage: typeof error === 'string' ? error : error.message, + actionURL: getGithubActionURL(), + } + + return sendSlackWebhook(Config.slackWebhookUrlFailure, payload) +} + +// ---- +// Helper functions +// ---- + +function formatFigmaVariableValue( + value: FigmaVariableValue, + resolvedType: VariableCreate['resolvedType'], +): string { + if (value === undefined) { + return '(not set)' + } + + if (isFigmaAlias(value)) { + return `ALIAS(${value.id})` + } + // if color, denormalizeRGBA and open in tinycolor + if (resolvedType === 'COLOR' && typeof value === 'object' && 'r' in value) { + const denormalized = denormalizeRGBA(value) + // we want to return the hex and the alpha value seperated (e.g. #000000 24%) + // the percentage should be rounded to two decimal places + const tinyColor = tinycolor(denormalized) + return `${tinyColor.toHexString().toUpperCase()} ${roundTo(tinyColor.getAlpha() * 100)}%` + } + // if a float, round to to four decimal places + if (resolvedType === 'FLOAT') { + return roundTo(value as number, 4).toString() + } + return value.toString() +} + +async function sendSlackWebhook( + webookUrl: string, + payload: Record, +) { + // first we need to ensure that all the values in the payload object are strings + const stringifiedPayload = Object.entries(payload).reduce( + (acc, [key, value]) => { + acc[key] = (value as string).toString() + return acc + }, + {} as Record, + ) + + console.info('Sending Slack webhook:', JSON.stringify(stringifiedPayload)) + + try { + const res = await fetch(webookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(stringifiedPayload), + }) + if (!res.ok) { + console.error('Error sending Slack webhook:', res.statusText) + summary.addSeparator() + summary.addEOL().addRaw('> [!WARNING]').addEOL() + summary + .addRaw('> An error occurred while sending the Slack webhook.') + .addEOL() + // if there is a status text, we add it to the summary + if (res?.statusText.trim() !== '') { + summary.addEOL().addRaw(`> \`\`\``).addEOL() + summary.addEOL().addRaw(`> ${res.statusText}`).addEOL() + summary.addEOL().addRaw(`> \`\`\``).addEOL() + } + summary.write() + } else { + console.info('Slack webhook sent successfully.') + } + } catch (error) { + console.error('Error sending Slack webhook:', error) + summary.addSeparator() + summary.addEOL().addRaw('> [!WARNING]').addEOL() + summary + .addRaw('> An error occurred while sending the Slack webhook.') + .addEOL() + summary + .addRaw(`> Error Message: \`${(error as Error).toString()}\``) + .addEOL() + summary.write() + } +} + +function getGithubActionURL() { + const runId = process.env.GITHUB_RUN_ID + const repo = process.env.GITHUB_REPOSITORY + + if (!runId || !repo) { + return 'https://github.com' + } + + return `https://github.com/${repo}/actions/runs/${runId}` +} diff --git a/src/workflow/summary.ts b/src/workflow/summary.ts new file mode 100644 index 0000000..5fbbf6c --- /dev/null +++ b/src/workflow/summary.ts @@ -0,0 +1,409 @@ +// Copied from https://github.com/actions/toolkit + +/* The MIT License (MIT) + +Copyright 2019 GitHub + +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.*/ + +import { EOL } from 'os' +import { constants, promises } from 'fs' +const { access, appendFile, writeFile } = promises + +export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' + +export type SummaryTableRow = (SummaryTableCell | string)[] + +export interface SummaryTableCell { + /** + * Cell content + */ + data: string + /** + * Render cell as header + * (optional) default: false + */ + header?: boolean + /** + * Number of columns the cell extends + * (optional) default: '1' + */ + colspan?: string + /** + * Number of rows the cell extends + * (optional) default: '1' + */ + rowspan?: string +} + +export interface SummaryImageOptions { + /** + * The width of the image in pixels. Must be an integer without a unit. + * (optional) + */ + width?: string + /** + * The height of the image in pixels. Must be an integer without a unit. + * (optional) + */ + height?: string +} + +export interface SummaryWriteOptions { + /** + * Replace all existing content in summary file with buffer contents + * (optional) default: false + */ + overwrite?: boolean +} + +class Summary { + private _buffer: string + private _filePath?: string | null + + constructor() { + this._buffer = '' + } + + /** + * Finds the summary file path from the environment, rejects if env var is not found or file does not exist + * Also checks r/w permissions. + * + * @returns step summary file path + */ + private async filePath(): Promise { + if (this._filePath) { + return this._filePath + } + + const pathFromEnv = process.env[SUMMARY_ENV_VAR] + if (!pathFromEnv) { + this._filePath = null + return this._filePath + } + + try { + await access(pathFromEnv, constants.R_OK | constants.W_OK) + } catch { + throw new Error( + `Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.`, + ) + } + + this._filePath = pathFromEnv + return this._filePath + } + + /** + * Wraps content in an HTML tag, adding any HTML attributes + * + * @param {string} tag HTML tag to wrap + * @param {string | null} content content within the tag + * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add + * + * @returns {string} content wrapped in HTML element + */ + wrap( + tag: string, + content: string | null, + attrs: { [attribute: string]: string } = {}, + ): string { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join('') + + if (!content) { + return `<${tag}${htmlAttrs}>` + } + + return `<${tag}${htmlAttrs}>${content}` + } + + /** + * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. + * + * @param {SummaryWriteOptions} [options] (optional) options for write operation + * + * @returns {Promise} summary instance + */ + async write(options?: SummaryWriteOptions): Promise { + const overwrite = !!options?.overwrite + const filePath = await this.filePath() + + // if there is no file path, print to console + if (!filePath) { + console.log( + `~~~ SUMMARY ~~~${EOL}${this._buffer}${EOL}~~~ END SUMMARY ~~~`, + ) + return this.emptyBuffer() + } + + const writeFunc = overwrite ? writeFile : appendFile + await writeFunc(filePath, this._buffer, { encoding: 'utf8' }) + return this.emptyBuffer() + } + + /** + * Clears the summary buffer and wipes the summary file + * + * @returns {Summary} summary instance + */ + async clear(): Promise { + return this.emptyBuffer().write({ overwrite: true }) + } + + /** + * Returns the current summary buffer as a string + * + * @returns {string} string of summary buffer + */ + stringify(): string { + return this._buffer + } + + /** + * If the summary buffer is empty + * + * @returns {boolen} true if the buffer is empty + */ + isEmptyBuffer(): boolean { + return this._buffer.length === 0 + } + + /** + * Resets the summary buffer without writing to summary file + * + * @returns {Summary} summary instance + */ + emptyBuffer(): Summary { + this._buffer = '' + return this + } + + /** + * Adds raw text to the summary buffer + * + * @param {string} text content to add + * @param {boolean} [addEOL=false] (optional) append an EOL to the raw text (default: false) + * + * @returns {Summary} summary instance + */ + addRaw(text: string, addEOL = false): Summary { + this._buffer += text + return addEOL ? this.addEOL() : this + } + + /** + * Adds the operating system-specific end-of-line marker to the buffer + * + * @returns {Summary} summary instance + */ + addEOL(): Summary { + return this.addRaw(EOL) + } + + /** + * Adds an HTML codeblock to the summary buffer + * + * @param {string} code content to render within fenced code block + * @param {string} lang (optional) language to syntax highlight code + * + * @returns {Summary} summary instance + */ + addCodeBlock(code: string, lang?: string): Summary { + const attrs = { + ...(lang && { lang }), + } + const element = this.wrap('pre', this.wrap('code', code), attrs) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML list to the summary buffer + * + * @param {string[]} items list of items to render + * @param {boolean} [ordered=false] (optional) if the rendered list should be ordered or not (default: false) + * + * @returns {Summary} summary instance + */ + addList(items: string[], ordered = false): Summary { + const tag = ordered ? 'ol' : 'ul' + const listItems = items.map((item) => this.wrap('li', item)).join('') + const element = this.wrap(tag, listItems) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML table to the summary buffer + * + * @param {SummaryTableCell[]} rows table rows + * + * @returns {Summary} summary instance + */ + addTable(rows: SummaryTableRow[]): Summary { + const tableBody = rows + .map((row) => { + const cells = row + .map((cell) => { + if (typeof cell === 'string') { + return this.wrap('td', cell) + } + + const { header, data, colspan, rowspan } = cell + const tag = header ? 'th' : 'td' + const attrs = { + ...(colspan && { colspan }), + ...(rowspan && { rowspan }), + } + + return this.wrap(tag, data, attrs) + }) + .join('') + + return this.wrap('tr', cells) + }) + .join('') + + const element = this.wrap('table', tableBody) + return this.addRaw(element).addEOL() + } + + /** + * Adds a collapsable HTML details element to the summary buffer + * + * @param {string} label text for the closed state + * @param {string} content collapsable content + * + * @returns {Summary} summary instance + */ + addDetails(label: string, content: string): Summary { + const element = this.wrap('details', this.wrap('summary', label) + content) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML image tag to the summary buffer + * + * @param {string} src path to the image you to embed + * @param {string} alt text description of the image + * @param {SummaryImageOptions} options (optional) addition image attributes + * + * @returns {Summary} summary instance + */ + addImage(src: string, alt: string, options?: SummaryImageOptions): Summary { + const { width, height } = options || {} + const attrs = { + ...(width && { width }), + ...(height && { height }), + } + + const element = this.wrap('img', null, { src, alt, ...attrs }) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML section heading element + * + * @param {string} text heading text + * @param {number | string} [level=1] (optional) the heading level, default: 1 + * + * @returns {Summary} summary instance + */ + addHeading(text: string, level?: number | string): Summary { + const tag = `h${level}` + const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) + ? tag + : 'h1' + const element = this.wrap(allowedTag, text) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML thematic break (
) to the summary buffer + * + * @returns {Summary} summary instance + */ + addSeparator(): Summary { + const element = this.wrap('hr', null) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML line break (
) to the summary buffer + * + * @returns {Summary} summary instance + */ + addBreak(): Summary { + const element = this.wrap('br', null) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML blockquote to the summary buffer + * + * @param {string} text quote text + * @param {string} cite (optional) citation url + * + * @returns {Summary} summary instance + */ + addQuote(text: string, cite?: string): Summary { + const attrs = { + ...(cite && { cite }), + } + const element = this.wrap('blockquote', text, attrs) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML anchor tag to the summary buffer + * + * @param {string} text link text/content + * @param {string} href hyperlink + * + * @returns {Summary} summary instance + */ + addLink(text: string, href: string): Summary { + const element = this.wrap('a', text, { href }) + return this.addRaw(element).addEOL() + } + + /** + * Adds a blockquote alert to the summary buffer + * + * @param {string} type type of alert + * @param {string} text alert text + * + * @returns {Summary} summary instance + */ + addAlert( + type: 'note' | 'tip' | 'important' | 'warning' | 'caution', + text: string, + ): Summary { + const element = text + .split(EOL) + .map((line) => `> ${line}`) + .join(EOL) + const alert = `> [!${type.toUpperCase()}]${EOL}${element}` + return this.addRaw(alert).addEOL() + } +} + +const _summary = new Summary() + +export const summary = _summary diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1c57118 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["src/**/*", "src/index.d.ts"], + "compilerOptions": { + "target": "es2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "./src", + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "removeComments": true, + "resolveJsonModule": true, + "baseUrl": "./src", + "paths": { + "@src/*": ["*"] + } + } +}