diff --git a/.github/workflows/sync-figma-tokens.yml b/.github/workflows/sync-figma-tokens.yml new file mode 100644 index 0000000000..dd73bb4575 --- /dev/null +++ b/.github/workflows/sync-figma-tokens.yml @@ -0,0 +1,32 @@ +name: Sync tokens to Figma + +on: + workflow_dispatch: + push: + branches: + - production + paths: + - "tokens/movistar.json" + - "tokens/vivo-new.json" + - "tokens/o2-new.json" + - "tokens/telefonica.json" + - "tokens/blau.json" + - "tokens/tu.json" + +jobs: + sync-figma-brand: + runs-on: ubuntu-latest + + env: + FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Run sync for the brand(s) + working-directory: tokens/figma + run: node index.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 9a439d9427..0000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Sync tokens to Figma - -on: - push: - paths: - - "tokens/movistar.json" - - "tokens/vivo.json" - - "tokens/telefonica.json" - workflow_dispatch: - inputs: - movistar: - type: boolean - default: true - vivo-new: - type: boolean - default: true - o2-new: - type: boolean - default: true - telefonica: - type: boolean - default: true - blau: - type: boolean - default: true - tu: - type: boolean - default: true - -jobs: - sync-figma-brand: - runs-on: ubuntu-latest - - env: - FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }} - MOVISTAR_FILE_KEY: ${{ secrets.MOVISTAR_FILE_KEY }} - VIVO_FILE_KEY: ${{ secrets.VIVO_FILE_KEY }} - TELEFONICA_FILE_KEY: ${{ secrets.TELEFONICA_FILE_KEY }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Determine Execution Mode - id: determine-mode - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Get the name of the modified file (e.g., "movistar.json") - FILE_NAME=$(git diff --name-only HEAD^ HEAD | grep 'tokens/' | xargs basename) - # Remove the ".json" extension to get the brand name - BRAND_NAME="${FILE_NAME%.json}" - echo "Triggered by push. Brand: $BRAND_NAME" - echo "BRAND_NAME=$BRAND_NAME" >> $GITHUB_ENV - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - selected_brands="" - if [ "${{ github.event.inputs.movistar }}" == "true" ]; then - selected_brands+=" movistar" - fi - if [ "${{ github.event.inputs.vivo-new }}" == "true" ]; then - selected_brands+=" vivo-new" - fi - if [ "${{ github.event.inputs.o2-new }}" == "true" ]; then - selected_brands+=" o2-new" - fi - if [ "${{ github.event.inputs.telefonica }}" == "true" ]; then - selected_brands+=" telefonica" - fi - if [ "${{ github.event.inputs.blau }}" == "true" ]; then - selected_brands+=" blau" - fi - if [ "${{ github.event.inputs.tu }}" == "true" ]; then - selected_brands+=" tu" - fi - echo "Triggered by workflow dispatch. Selected brands: $selected_brands" - echo "BRAND_NAME=$selected_brands" >> $GITHUB_ENV - fi - - - name: Run sync for the brand(s) - working-directory: tokens/figma - run: | - for brand in $BRAND_NAME; do - echo "Syncing brand: $brand" - node index.mjs "$brand" - done diff --git a/.gitignore b/.gitignore index af2e0e9c7d..08368f5983 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ Temporary Items **/node_modules -**/build \ No newline at end of file +**/build + +.env \ No newline at end of file diff --git a/README.md b/README.md index 974c5f9085..9365cb85db 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@   -| Other Mística Repos | Description | -| :--------------------------------------------------------------- | :-------------------------------------------------------- | -| [mistica-web](https://github.com/Telefonica/mistica-web) | Repository with code libraries for Mística in web | -| [mistica-ios](https://github.com/Telefonica/mistica-ios) | Repository with code libraries for Mística in iOS | -| [mistica-android](https://github.com/Telefonica/mistica-android) | Repository with code libraries for Mística in Android | -| [mistica-icons](https://github.com/Telefonica/mistica-icons) | The source of truth for icons in our digital products | +| Other Mística Repos | Description | +| :--------------------------------------------------------------- | :---------------------------------------------------- | +| [mistica-web](https://github.com/Telefonica/mistica-web) | Repository with code libraries for Mística in web | +| [mistica-ios](https://github.com/Telefonica/mistica-ios) | Repository with code libraries for Mística in iOS | +| [mistica-android](https://github.com/Telefonica/mistica-android) | Repository with code libraries for Mística in Android | +| [mistica-icons](https://github.com/Telefonica/mistica-icons) | The source of truth for icons in our digital products | --- @@ -33,15 +33,5 @@ ## How to sync design tokens -If you want to sync design tokens with Figma files you can use [Figma Tokens plugin](https://www.figma.com/community/plugin/843461159747178978/Figma-Tokens) and setup the plugin with the following information. - -1. Open Figma Tokens Plugin, go to `Settings` and select `Github` in Token Storage -2. Add new credentials - -- **Name:** The name of the brand -- **Personal Access Token:** you have to generate a token from Github. [Read how to do it](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-personal-access-token-classic) -- **Repository:** `Telefonica/mistica-design` -- **Default Branch:** `production` -- **File Path:** `tokens/brandName.json` (see files [here](./tokens/)) - -![image](https://user-images.githubusercontent.com/6722153/166447592-e3d1b545-199d-4155-9024-2fb88351b444.png) 3. Finally, go to `Tokens`, select `Global` and `Apply to document` and clic in `Update` +> [!NOTE] +> We're no longer using [Figma Tokens plugin](https://www.figma.com/community/plugin/843461159747178978/Figma-Tokens) to sync tokens with files. We use Figma API to update variables in our libraries. More about this proccess in [Figma tokens script README](https://github.com/Telefonica/mistica-design/blob/production/tokens/figma/README.md). diff --git a/package.json b/package.json new file mode 100644 index 0000000000..e9bc0601ff --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "dotenv": "^16.4.5", + "node-fetch": "^3.3.2" + } +} diff --git a/tokens/figma/README.md b/tokens/figma/README.md new file mode 100644 index 0000000000..8f78cb592e --- /dev/null +++ b/tokens/figma/README.md @@ -0,0 +1,54 @@ +# Project overview + +This project is designed to update Figma variables based on a JSON input, primarily focused on managing brand themes, colors, and other design tokens. The project retrieves existing variables from Figma, processes the provided JSON data, and updates or creates new variables in collections "Mode" and "Brand". + +## Features + +- **Fetch existing Figma data**: Retrieves the existing variables and collections from Figma. +- **Process JSON data**: Extracts theme and token data from provided JSON files for each brand. +- **Update or create variables**: Adds new variables or updates existing ones based on the brand's light and dark themes. +- **Handle variable modes**: Ensures each brand's mode (e.g., "Light", "Dark") is updated or created in the Figma "Brand" collection. +- **Support for multiple brands**: Processes multiple brands, mapping each brand's unique variables into Figma's collections. + +## Setup + +### Environment variables: + +- `FIGMA_TOKEN`: The API token to authenticate with Figma. + +### Dependencies: + +- Node.js and packages such as `node-fetch`, `dotenv`, and `fs` are used to manage API requests, read local files, and load environment variables. + +## Key functions + +### `updateModeCollection(jsonData, brand)` + +This function updates the color-scheme variables in Figma for a specific brand. It: + +- Fetches the current variables from Figma. +- Updates modes and variables for `"Light"` and `"Dark"` color-schemes. +- Sends a POST request to update Figma with the new data. + +### `updateBrandCollection(jsonData)` + +This function focuses on updating color variables in the "Brand" collection. It: + +- Maps color variables from the "Mode" collection to the "Brand" collection. +- Adds non-color variables for each brand. +- Creates or updates modes for each brand. +- Ensures proper aliasing of variables between collections. + +## Usage + +1. Navigate to the `tokens/figma` directory: + + ```bash + cd tokens/figma + + ``` + +2. Run the script + ```bash + node index.mjs + ``` diff --git a/tokens/figma/config.mjs b/tokens/figma/config.mjs new file mode 100644 index 0000000000..5e484054a8 --- /dev/null +++ b/tokens/figma/config.mjs @@ -0,0 +1,20 @@ +import dotenv from "dotenv"; + +dotenv.config({ path: "../../.env" }); + +import { BRANDS } from "./utils/constants.mjs"; + +export const BRAND_KEY = { + [BRANDS.MOVISTAR]: "ObNHOLPtrIytjy9BH7M9jW", + [BRANDS.O2_NEW]: "CjvgrHEIycSQ6exznxnFXT", + [BRANDS.VIVO_NEW]: "EApRpjaTyUOwW5VQU2ZqgP", + [BRANDS.TELEFONICA]: "m8srmP3eedfvDaqYnbM6PI", + [BRANDS.BLAU]: "czemeClWRGBI8oF7caNa5m", + [BRANDS.TU]: "19IXMaFqdYeC1IIdTwXBgY", +}; + +export const MIDDLEWARE_KEY = + "w7fBxCsEb8WrMVVuxDnCQd"; + +export const FIGMA_TOKEN = + process.env.FIGMA_TOKEN; diff --git a/tokens/figma/index.mjs b/tokens/figma/index.mjs new file mode 100644 index 0000000000..7bcfe91623 --- /dev/null +++ b/tokens/figma/index.mjs @@ -0,0 +1,40 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { updateSkinFiles } from "./update-skins.mjs"; +import { updateMiddleware } from "./update-middleware.mjs"; + +import { + extractSkinJsonData, + extractMiddlewareJsonData, +} from "./utils/extract-json-data.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const tokensPath = path.resolve(__dirname, "../"); + +const files = fs.readdirSync(tokensPath); + +const jsonFiles = files.filter((file) => + file.endsWith(".json") +); + +const jsonDataForSkin = extractSkinJsonData( + jsonFiles, + tokensPath +); + +const jsonDataForMiddleware = + extractMiddlewareJsonData( + jsonFiles, + tokensPath + ); + +async function processAll() { + await updateSkinFiles(jsonDataForSkin); + await updateMiddleware(jsonDataForMiddleware); +} + +processAll(); diff --git a/tokens/figma/update-middleware.mjs b/tokens/figma/update-middleware.mjs new file mode 100644 index 0000000000..20fce9d279 --- /dev/null +++ b/tokens/figma/update-middleware.mjs @@ -0,0 +1,523 @@ +import { + updateCollections, + updateOrCreateModes, + updateOrCreateVariables, + updateOrCreateVariableModeValues, + hasDefaultMode, +} from "./utils/figma-utils.mjs"; + +import { + VARIABLE_TYPES, + COLLECTION_NAMES, + MODE_NAMES, + VARIABLE_SCOPES, +} from "./utils/constants.mjs"; + +import { + getFigmaData, + postFigmaVariables, +} from "./utils/api-request.mjs"; + +import { + getConstantVariables, + getNonColorVariables, +} from "./variables.mjs"; + +import formatBrandName from "./utils/format-brand-name.mjs"; + +import { + BRAND_KEY, + MIDDLEWARE_KEY, +} from "./config.mjs"; + +const brandNames = Object.keys(BRAND_KEY); + +async function updateModeCollection( + jsonData, + brand +) { + try { + const figmaData = await getFigmaData( + MIDDLEWARE_KEY + ); + const existingVariables = + figmaData.meta.variables; + const existingCollections = + figmaData.meta.variableCollections; + + const newData = { + variableCollections: [], + variableModes: [], + variables: [], + variableModeValues: [], + }; + + const modes = [ + MODE_NAMES.LIGHT, + MODE_NAMES.DARK, + ]; + + // Create or update modes for the collection + const defaultMode = modes[0]; + const defaultModeResult = + await updateOrCreateModes({ + mode: { name: defaultMode }, + isDefault: true, + targetCollectionName: + COLLECTION_NAMES.COLOR_SCHEME, + existingCollections: existingCollections, + }); + newData.variableModes.push(defaultModeResult); + + const modeResults = await Promise.all( + modes.slice(1).map(async (mode) => { + return await updateOrCreateModes({ + mode: { name: mode }, + isDefault: false, + targetCollectionName: + COLLECTION_NAMES.COLOR_SCHEME, + existingCollections: + existingCollections, + }); + }) + ); + newData.variableModes.push(...modeResults); + + // Get color variables using the imported function + const colorVariables = getConstantVariables( + jsonData, + brand + ); + + const processedVariables = new Map(); + + for (const variableGroup of colorVariables) { + for (const variable of variableGroup.variables) { + const prefixedName = `${brand}/${variable.name}`; + + // Only process if the variable hasn't been created yet + if ( + !processedVariables.has(prefixedName) + ) { + // Create or update the variable + const variableData = + await updateOrCreateVariables({ + variable: { + name: prefixedName, + resolvedType: + VARIABLE_TYPES.COLOR, + scopes: [], + }, + targetCollectionName: + COLLECTION_NAMES.COLOR_SCHEME, + existingVariables: + existingVariables, + existingCollections: + existingCollections, + }); + + newData.variables.push(variableData); + processedVariables.set( + prefixedName, + variableData + ); + + // Find values for light and dark modes + const lightValue = ( + jsonData[brand]?.light || [] + ).find( + (v) => v.name === variable.name + )?.value; + const darkValue = ( + jsonData[brand]?.dark || [] + ).find( + (v) => v.name === variable.name + )?.value; + + // Handle light mode value + if (lightValue) { + const lightModeValueData = + await updateOrCreateVariableModeValues( + { + variable: { + name: prefixedName, + value: lightValue, + hasAlias: false, + }, + targetModeName: hasDefaultMode( + COLLECTION_NAMES.COLOR_SCHEME, + existingCollections + ) + ? MODE_NAMES.DEFAULT + : MODE_NAMES.LIGHT, + targetCollectionName: + COLLECTION_NAMES.COLOR_SCHEME, + existingCollections: + existingCollections, + existingVariables: + existingVariables, + } + ); + + if (lightModeValueData) { + newData.variableModeValues.push( + lightModeValueData + ); + } + } + + // Handle dark mode value + if (darkValue) { + const darkModeValueData = + await updateOrCreateVariableModeValues( + { + variable: { + name: prefixedName, + value: darkValue, + hasAlias: false, + }, + targetModeName: MODE_NAMES.DARK, + targetCollectionName: + COLLECTION_NAMES.COLOR_SCHEME, + existingCollections: + existingCollections, + existingVariables: + existingVariables, + } + ); + + if (darkModeValueData) { + newData.variableModeValues.push( + darkModeValueData + ); + } + } + } + } + } + + // Update the variables and modes in Figma + await postFigmaVariables( + MIDDLEWARE_KEY, + newData + ); + + return newData; + } catch (error) { + console.error("Error:", error); + throw error; + } +} + +async function updateBrandCollection(jsonData) { + try { + // Step 1: Fetch the existing data from Figma + + const figmaData = await getFigmaData( + MIDDLEWARE_KEY + ); + const existingCollections = + figmaData.meta.variableCollections; + + const existingVariables = + figmaData.meta.variables || {}; + + // Step 2: Find the Theme and Brand collections + + const themeCollection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === + COLLECTION_NAMES.COLOR_SCHEME + ); + + const brandCollection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === COLLECTION_NAMES.SKIN + ); + + // Step 3: Filter variables to only include those from the "Mode" collection + + const existingModeVariables = Object.values( + existingVariables + ).filter( + (variable) => + variable.variableCollectionId === + themeCollection.id + ); + + const existingBrandVariables = Object.values( + existingVariables + ).filter( + (variable) => + variable.variableCollectionId === + brandCollection.id + ); + + // Step 4: Prepare new variables data for the Brand collection + const newData = { + variables: [], + variableModeValues: [], + variableModes: [], + }; + + // Step 5: Create or update modes based on the brands + + const firstBrand = brandNames[0]; + + const firstModeResult = + await updateOrCreateModes({ + mode: { + name: formatBrandName(firstBrand), + }, + isDefault: true, + targetCollectionName: + COLLECTION_NAMES.SKIN, + existingCollections: existingCollections, + }); + + newData.variableModes.push(firstModeResult); + + brandNames.slice(1).forEach(async (brand) => { + const formattedBrand = + formatBrandName(brand); + + const modeResult = + await updateOrCreateModes({ + mode: { name: formattedBrand }, + isDefault: false, + targetCollectionName: + COLLECTION_NAMES.SKIN, + existingCollections: + existingCollections, + }); + + newData.variableModes.push(modeResult); + }); + + // Step 6: Create a map for color variables from Mode collection + + const variableToBrandMap = new Map(); + + existingModeVariables.forEach((variable) => { + if ( + variable.resolvedType === + VARIABLE_TYPES.COLOR + ) { + const variableName = variable.name + .split("/") + .pop(); + if ( + !variableToBrandMap.has(variableName) + ) { + variableToBrandMap.set( + variableName, + {} + ); + } + const brand = variable.name.split("/")[0]; + variableToBrandMap.get(variableName)[ + brand + ] = variable.id; + } + }); + + for (let [ + variableName, + brandMap, + ] of variableToBrandMap) { + // Return empty scopes in gradient variables, since they already have a style + let scopes = [VARIABLE_SCOPES.ALL_SCOPES]; + + const stopRegex = /-stop-\d+$/; + + if (stopRegex.test(variableName)) { + scopes = []; + } + + const variable = { + name: variableName, + resolvedType: VARIABLE_TYPES.COLOR, + scopes: scopes, + targetCollectionName: + COLLECTION_NAMES.SKIN, + }; + + const variableData = + await updateOrCreateVariables({ + variable, + targetCollectionName: + variable.targetCollectionName, + existingVariables: + existingBrandVariables, + existingCollections: + existingCollections, + }); + + newData.variables.push(variableData); + + // Step 8: Update mode values with the correct aliases for each brand + for (const brand of brandNames) { + const formattedBrand = + formatBrandName(brand); + + // Call the helper function to create or update variable mode values + const variableModeValuesData = + await updateOrCreateVariableModeValues({ + variable: { + name: variableName, + hasAlias: true, + value: brandMap[brand], // Alias to the Theme variable ID for the brand + }, + + targetModeName: + hasDefaultMode( + COLLECTION_NAMES.SKIN, + existingCollections + ) && brand === brandNames[0] + ? MODE_NAMES.DEFAULT + : formattedBrand, + targetCollectionName: + COLLECTION_NAMES.SKIN, + existingCollections: + existingCollections, + existingVariables: + existingBrandVariables, + }); + + if (variableModeValuesData) { + newData.variableModeValues.push( + variableModeValuesData + ); + } + } + } + + // Loop through each brand to process its specific tokens + for (const brand of brandNames) { + const nonColorVariables = + getNonColorVariables(jsonData, brand); + + for (const group of nonColorVariables) { + const { + variables, + collectionName, + resolvedType, + variableScopes, + hasAlias, + } = group; + + for (const variable of variables) { + // Update or create the variable in the collection + const variableUpdateResult = + await updateOrCreateVariables({ + variable: { + ...variable, + resolvedType: resolvedType, + scopes: variableScopes, + hasAlias: hasAlias, + }, + targetCollectionName: + collectionName, + existingVariables: + existingVariables, + existingCollections: + existingCollections, + }); + + if (!newData.variables) { + newData.variables = []; + } + newData.variables.push( + variableUpdateResult + ); + + // Find the mode for the current brand and set the mode values correctly + const variableModeValuesUpdatedResult = + await updateOrCreateVariableModeValues( + { + variable: { + ...variable, + resolvedType: resolvedType, + scopes: variableScopes, + hasAlias: hasAlias, + }, + targetModeName: + hasDefaultMode( + collectionName, + existingCollections + ) && brand === brandNames[0] + ? MODE_NAMES.DEFAULT + : formatBrandName(brand), + targetCollectionName: + collectionName, + existingCollections: + existingCollections, + existingVariables: + existingVariables, + } + ); + + newData.variableModeValues.push( + variableModeValuesUpdatedResult + ); + } + } + } + + // Step 9: Send the data to update the Brand collection (POST) + + await postFigmaVariables( + MIDDLEWARE_KEY, + newData + ); + + return newData; // Returning newData for debugging + } catch (error) { + console.error("Error:", error); + throw error; + } +} + +async function postCollections(brand) { + const collectionNames = [ + COLLECTION_NAMES.SKIN, + COLLECTION_NAMES.COLOR_SCHEME, + ]; + + try { + const newData = await updateCollections( + collectionNames, + MIDDLEWARE_KEY + ); + + await postFigmaVariables( + MIDDLEWARE_KEY, + newData + ); + } catch (error) { + console.error( + `Error creating collections for brand ${brand}:`, + error + ); + } +} + +async function processBrand(jsonData, brand) { + await postCollections(brand); + await updateModeCollection(jsonData, brand); +} + +async function processAllBrands(jsonData) { + for (const brand of brandNames) { + await processBrand(jsonData, brand); + } +} + +export async function updateMiddleware(jsonData) { + await processAllBrands(jsonData); + await updateBrandCollection(jsonData); +} diff --git a/tokens/figma/update-skins.mjs b/tokens/figma/update-skins.mjs new file mode 100644 index 0000000000..15cac81c8a --- /dev/null +++ b/tokens/figma/update-skins.mjs @@ -0,0 +1,147 @@ +import { + updateCollections, + updateOrCreateVariables, + updateOrCreateVariableModeValues, +} from "./utils/figma-utils.mjs"; + +import { + COLLECTION_NAMES, + MODE_NAMES, +} from "./utils/constants.mjs"; + +import { + getFigmaData, + postFigmaVariables, +} from "./utils/api-request.mjs"; + +import { getPaletteVariables } from "./variables.mjs"; + +import { BRAND_KEY } from "./config.mjs"; + +const collectionNames = [ + COLLECTION_NAMES.PALETTE, +]; + +async function updatePalette(jsonData, brand) { + const FILE_KEY = BRAND_KEY[brand]; + try { + const figmaData = await getFigmaData( + FILE_KEY + ); + + const existingVariables = + figmaData.meta.variables; + const existingCollections = + figmaData.meta.variableCollections; + + const newData = { + variableCollections: [], + variableModes: [], + variables: [], + variableModeValues: [], + }; + + const paletteVariables = getPaletteVariables( + jsonData, + brand + ); + + for (const group of paletteVariables) { + const { + variables, + collectionName, + resolvedType, + variableScopes, + hasAlias, + } = group; + + for (const variable of variables) { + // Update or create the variable in the collection + const variablesUpdateResult = + await updateOrCreateVariables({ + variable: { + ...variable, + resolvedType: resolvedType, + scopes: variableScopes, + hasAlias: hasAlias, + }, + targetCollectionName: collectionName, + existingVariables: existingVariables, + existingCollections: + existingCollections, + }); + + newData.variables.push( + variablesUpdateResult + ); + + // Find the mode for the current brand and set the mode values correctly + const variableModeValuesUpdatedResult = + await updateOrCreateVariableModeValues({ + variable: { + ...variable, + resolvedType: resolvedType, + scopes: variableScopes, + hasAlias: hasAlias, + }, + targetModeName: MODE_NAMES.DEFAULT, + targetCollectionName: collectionName, + existingCollections: + existingCollections, + existingVariables: existingVariables, + }); + + newData.variableModeValues.push( + variableModeValuesUpdatedResult + ); + } + } + + return newData; + } catch (error) { + console.error("Error:", error); + throw error; // rethrow the error to be handled later + } +} + +async function postCollections(brand) { + const FILE_KEY = BRAND_KEY[brand]; + + try { + const newData = await updateCollections( + collectionNames, + FILE_KEY + ); + + await postFigmaVariables(FILE_KEY, newData); + } catch (error) { + console.error( + `Error creating collections for for brand ${brand}:`, + error + ); + } +} + +async function postPalette(jsonData, brand) { + const FILE_KEY = BRAND_KEY[brand]; + try { + const newData = await updatePalette( + jsonData, + brand + ); + + await postFigmaVariables(FILE_KEY, newData); + } catch (error) { + console.error( + `Error updating palette for brand ${brand}:`, + error + ); + } +} + +export async function updateSkinFiles(jsonData) { + for (const brand of Object.keys(BRAND_KEY)) { + await postCollections(brand); + await postPalette(jsonData, brand); + } +} diff --git a/tokens/figma/utils/api-request.mjs b/tokens/figma/utils/api-request.mjs new file mode 100644 index 0000000000..e333cc9696 --- /dev/null +++ b/tokens/figma/utils/api-request.mjs @@ -0,0 +1,48 @@ +import fetch from "node-fetch"; + +import { FIGMA_TOKEN } from "../config.mjs"; + +async function getFigmaData(FILE_KEY) { + const response = await fetch( + `https://api.figma.com/v1/files/${FILE_KEY}/variables/local`, + { + method: "GET", + headers: { + "X-Figma-Token": FIGMA_TOKEN, + "Content-Type": "application/json", + }, + } + ); + if (!response.ok) { + throw new Error( + `Error fetching Figma data: ${response.statusText}` + ); + } + return await response.json(); +} + +async function postFigmaVariables( + FILE_KEY, + newData +) { + const response = await fetch( + `https://api.figma.com/v1/files/${FILE_KEY}/variables`, + { + method: "POST", + headers: { + "X-Figma-Token": FIGMA_TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify(newData), + } + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Error updating variables: ${response.statusText}. Response: ${errorText}` + ); + } + return await response.json(); +} + +export { getFigmaData, postFigmaVariables }; diff --git a/tokens/figma/utils/constants.mjs b/tokens/figma/utils/constants.mjs new file mode 100644 index 0000000000..ada5bed0b4 --- /dev/null +++ b/tokens/figma/utils/constants.mjs @@ -0,0 +1,40 @@ +export const VARIABLE_TYPES = { + COLOR: "COLOR", + FLOAT: "FLOAT", + STRING: "STRING", + FONT_WEIGHT: "FONT_WEIGHT", + FONT_SIZE: "FONT_SIZE", + LINE_HEIGHT: "LINE_HEIGHT", + FONT_FAMILY: "FONT_FAMILY", +}; + +export const COLLECTION_NAMES = { + SKIN: "Brand", + COLOR_SCHEME: "Mode", + PALETTE: "Palette", +}; + +export const MODE_NAMES = { + DEFAULT: "Mode 1", + LIGHT: "Light", + DARK: "Dark", +}; + +export const VARIABLE_SCOPES = { + ALL_SCOPES: "ALL_SCOPES", + CORNER_RADIUS: "CORNER_RADIUS", + FONT_WEIGHT: "FONT_WEIGHT", + FONT_SIZE: "FONT_SIZE", + LINE_HEIGHT: "LINE_HEIGHT", + FONT_FAMILY: "FONT_FAMILY", + TEXT_CONTENT: "TEXT_CONTENT", +}; + +export const BRANDS = { + MOVISTAR: "movistar", + VIVO_NEW: "vivo-new", + O2_NEW: "o2-new", + TELEFONICA: "telefonica", + BLAU: "blau", + TU: "tu", +}; diff --git a/tokens/figma/utils/extract-json-data.mjs b/tokens/figma/utils/extract-json-data.mjs new file mode 100644 index 0000000000..2e6cbcc9c6 --- /dev/null +++ b/tokens/figma/utils/extract-json-data.mjs @@ -0,0 +1,802 @@ +import fs from "fs"; +import path from "path"; +import hexToRgba from "./hex-to-rgba.mjs"; + +export const extractSkinJsonData = ( + jsonFiles, + directoryPath +) => { + const allParsedContent = {}; // Initialize once to store all parsed content + + // First pass to gather allParsedContent + jsonFiles.forEach((file) => { + const filePath = path.resolve( + directoryPath, + file + ); + const fileContent = fs.readFileSync( + filePath, + "utf8" + ); + const parsedContent = JSON.parse(fileContent); + + // Extract file name without extension to use as a key + const fileName = file.split(".")[0]; + + // Store the parsed content in the allParsedContent object + allParsedContent[fileName] = parsedContent; + }); + + // Second pass to process each file + return jsonFiles.reduce((accumulator, file) => { + const filePath = path.resolve( + directoryPath, + file + ); + const fileContent = fs.readFileSync( + filePath, + "utf8" + ); + const parsedContent = JSON.parse(fileContent); + + // Extract file name without extension + const fileName = file.split(".")[0]; + + function processColors( + parsedContent, + theme, + allParsedContent + ) { + if (!["light", "dark"].includes(theme)) { + throw new Error( + `Invalid theme: ${theme}. Expected 'light' or 'dark'.` + ); + } + + const themeColors = parsedContent[theme]; + + function getPaletteName(value) { + const regexMatch = value.match( + /{palette\.(.*?)}/ + ); + if (regexMatch) { + return regexMatch[1]; + } + const rgbaMatch = value.match( + /rgba\(\{palette\.(.*?)\},\s*\d*\.?\d*\)/ + ); + if (rgbaMatch) { + return rgbaMatch[1]; + } + throw new Error( + `Unexpected color format: ${value}` + ); + } + + function getPaletteValue(colorName) { + const paletteValue = + parsedContent.global.palette[colorName] + ?.value; + if (!paletteValue) { + throw new Error( + `Color ${colorName} not found in palette` + ); + } + return paletteValue; + } + + function getMaxStopsAcrossBrands( + allParsedContent, + key, + theme + ) { + let maxStops = 0; + let isGradientInAnyBrand = false; + + Object.values(allParsedContent).forEach( + (content) => { + const colors = content[theme][key]; + if ( + colors && + colors.type === "linear-gradient" + ) { + isGradientInAnyBrand = true; + maxStops = Math.max( + maxStops, + colors.value.colors.length + ); + } + } + ); + + return { maxStops, isGradientInAnyBrand }; + } + + return Object.keys(themeColors).flatMap( + (key) => { + const colorData = themeColors[key]; + const { value, type } = colorData; + + const { + maxStops, + isGradientInAnyBrand, + } = getMaxStopsAcrossBrands( + allParsedContent, + key, + theme + ); + + // Handle gradients first + if ( + type === "linear-gradient" && + typeof value === "object" + ) { + // Map colors in gradient + return Array.from( + { length: maxStops }, + (_, index) => { + if (index < value.colors.length) { + // Use the actual gradient stop color + const color = + value.colors[index]; + const alphaMatch = + color.value.match( + /rgba\([^)]+,\s*([^)]+)\)/ + ); + const alpha = alphaMatch + ? alphaMatch[1] + : "1"; + const baseColorName = + getPaletteName(color.value); + + return alpha === "1" + ? { + name: `${theme}/${key}-stop-${ + index + 1 + }`, + value: baseColorName, + hasAlias: true, + } + : { + name: `${theme}/${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + hasAlias: false, + }; + } else if (isGradientInAnyBrand) { + // If this brand does not have a gradient, repeat the base color to match stops + const baseColorName = + getPaletteName( + value.colors[0].value + ); + return { + name: `${theme}/${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue( + baseColorName + ) + ), + hasAlias: false, + }; + } + } + ).filter(Boolean); + } + + // Handle solid colors or aliases when a gradient exists in other brands + if ( + type !== "linear-gradient" && + isGradientInAnyBrand + ) { + const baseColorName = + getPaletteName(value); + // Repeat the solid color to match the gradient stops + return Array.from( + { length: maxStops }, + (_, index) => ({ + name: `${theme}/${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue(baseColorName) + ), + hasAlias: false, + }) + ); + } + + // Handle solid colors or aliases normally + if ( + typeof value === "string" && + !value.startsWith("rgba") + ) { + const baseColorName = + getPaletteName(value); + + return { + name: `${theme}/${key}`, + value: hexToRgba( + getPaletteValue(baseColorName) + ), + + hasAlias: true, + }; + } + + // Handle rgba colors + if ( + typeof value === "string" && + value.startsWith("rgba") + ) { + const alphaMatch = value.match( + /rgba\([^)]+,\s*([^)]+)\)/ + ); + const alpha = alphaMatch + ? alphaMatch[1] + : "1"; + const baseColorName = + getPaletteName(value); + + return alpha === "1" + ? { + name: `${theme}/${key}`, + value: baseColorName, + hasAlias: true, + } + : { + name: `${theme}/${key}`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + hasAlias: false, + }; + } + + throw new Error( + `Unexpected color format for key: ${key}` + ); + } + ); + } + + // Other token processing logic + const paletteArray = Object.keys( + parsedContent.global.palette + ).map((key) => ({ + name: key, + value: hexToRgba( + parsedContent.global.palette[key].value + ), + })); + + const radiusArray = Object.keys( + parsedContent.radius + ).map((key) => ({ + name: key, + value: + typeof parsedContent.radius[key].value === + "string" + ? parsedContent.radius[key].value === + "circle" + ? 999 // If the value is "circle", set it to 999 + : parseFloat( + parsedContent.radius[key].value + ) // Otherwise, convert it to a float + : parsedContent.radius[key].value, // If it's not a string, use the original value + })); + + const fontWeightArray = Object.keys( + parsedContent.text.weight + ).map((key) => ({ + name: key, + value: parsedContent.text.weight[key].value, + })); + + const fontSizeArray = Object.keys( + parsedContent.text.size + ).flatMap((key) => { + const value = + parsedContent.text.size[key].value; + + // Check if the value is an object with mobile and desktop properties + if ( + typeof value === "object" && + value !== null + ) { + return [ + { + name: `mobile/${key}`, + value: parseFloat(value.mobile), + }, + { + name: `desktop/${key}`, + value: parseFloat(value.desktop), + }, + ]; + } + + // If value is not an object, return a single entry + return { + name: key, + value: parseFloat(value), + }; + }); + + const lineHeightArray = Object.keys( + parsedContent.text.lineHeight + ).flatMap((key) => { + const value = + parsedContent.text.lineHeight[key].value; + + // Check if the value is an object with mobile and desktop properties + if ( + typeof value === "object" && + value !== null + ) { + return [ + { + name: `mobile/${key}`, + value: parseFloat(value.mobile), + }, + { + name: `desktop/${key}`, + value: parseFloat(value.desktop), + }, + ]; + } + + // If value is not an object, return a single entry + return { + name: key, + value: parseFloat(value), + }; + }); + + // Accumulate results + accumulator[fileName] = { + light: processColors( + parsedContent, + "light", + allParsedContent + ), + dark: processColors( + parsedContent, + "dark", + allParsedContent + ), + palette: paletteArray, + radius: radiusArray, + fontWeight: fontWeightArray, + fontSize: fontSizeArray, + lineHeight: lineHeightArray, + }; + + return accumulator; + }, {}); +}; + +export const extractMiddlewareJsonData = ( + jsonFiles, + directoryPath +) => { + const allParsedContent = {}; // Initialize once to store all parsed content + + // First pass to gather allParsedContent + jsonFiles.forEach((file) => { + const filePath = path.resolve( + directoryPath, + file + ); + const fileContent = fs.readFileSync( + filePath, + "utf8" + ); + const parsedContent = JSON.parse(fileContent); + + // Extract file name without extension to use as a key + const fileName = file.split(".")[0]; + + // Store the parsed content in the allParsedContent object + allParsedContent[fileName] = parsedContent; + }); + + // Second pass to process each file + return jsonFiles.reduce((accumulator, file) => { + const filePath = path.resolve( + directoryPath, + file + ); + const fileContent = fs.readFileSync( + filePath, + "utf8" + ); + const parsedContent = JSON.parse(fileContent); + + // Extract file name without extension + const fileName = file.split(".")[0]; + + function processColors( + parsedContent, + theme, + allParsedContent + ) { + if (!["light", "dark"].includes(theme)) { + throw new Error( + `Invalid theme: ${theme}. Expected 'light' or 'dark'.` + ); + } + + const themeColors = parsedContent[theme]; + + function getPaletteName(value) { + const regexMatch = value.match( + /{palette\.(.*?)}/ + ); + if (regexMatch) { + return regexMatch[1]; + } + const rgbaMatch = value.match( + /rgba\(\{palette\.(.*?)\},\s*\d*\.?\d*\)/ + ); + if (rgbaMatch) { + return rgbaMatch[1]; + } + throw new Error( + `Unexpected color format: ${value}` + ); + } + + function getPaletteValue(colorName) { + const paletteValue = + parsedContent.global.palette[colorName] + ?.value; + if (!paletteValue) { + throw new Error( + `Color ${colorName} not found in palette` + ); + } + return paletteValue; + } + + function getMaxStopsAcrossBrands( + allParsedContent, + key + ) { + let maxStops = 0; + let isGradientInAnyBrand = false; + + // Loop through each brand + Object.values(allParsedContent).forEach( + (content) => { + // Check both 'light' and 'dark' themes + ["light", "dark"].forEach((theme) => { + const colors = + content[theme]?.[key]; + if ( + colors && + colors.type === "linear-gradient" + ) { + isGradientInAnyBrand = true; + maxStops = Math.max( + maxStops, + colors.value.colors.length + ); + } + }); + } + ); + + return { maxStops, isGradientInAnyBrand }; + } + + return Object.keys(themeColors).flatMap( + (key) => { + const colorData = themeColors[key]; + const { value, type } = colorData; + + const { + maxStops, + isGradientInAnyBrand, + } = getMaxStopsAcrossBrands( + allParsedContent, + key, + theme + ); + + // Handle gradients first + if ( + type === "linear-gradient" && + typeof value === "object" + ) { + // Map colors in gradient + return Array.from( + { length: maxStops }, + (_, index) => { + if (index < value.colors.length) { + // Use the actual gradient stop color + const color = + value.colors[index]; + const alphaMatch = + color.value.match( + /rgba\([^)]+,\s*([^)]+)\)/ + ); + const alpha = alphaMatch + ? alphaMatch[1] + : "1"; + const baseColorName = + getPaletteName(color.value); + + return alpha === "1" + ? { + name: `${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + hasAlias: true, + description: + baseColorName, + } + : { + name: `${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + hasAlias: false, + description: + baseColorName, + }; + } else if (isGradientInAnyBrand) { + // If this brand does not have a gradient, repeat the base color to match stops + const baseColorName = + getPaletteName( + value.colors[0].value + ); + const alpha = 1; + return { + name: `${key}-stop-${ + index + 1 + }`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + + hasAlias: false, + description: baseColorName, + }; + } + } + ).filter(Boolean); + } + + // Handle solid colors or aliases when a gradient exists in other brands + if ( + type !== "linear-gradient" && + isGradientInAnyBrand + ) { + const baseColorName = + getPaletteName(value); + const alpha = 1; + // Repeat the solid color to match the gradient stops + return Array.from( + { length: maxStops }, + (_, index) => ({ + name: `${key}-stop-${index + 1}`, + value: hexToRgba( + getPaletteValue(baseColorName), + parseFloat(alpha) + ), + hasAlias: false, + description: baseColorName, + }) + ); + } + + // Handle solid colors or aliases normally + if ( + typeof value === "string" && + !value.startsWith("rgba") + ) { + const baseColorName = + getPaletteName(value); + const alpha = 1; + return { + name: `${key}`, + value: hexToRgba( + getPaletteValue(baseColorName), + parseFloat(alpha) + ), + + hasAlias: true, + description: baseColorName, + }; + } + + // Handle rgba colors + if ( + typeof value === "string" && + value.startsWith("rgba") + ) { + const alphaMatch = value.match( + /rgba\([^)]+,\s*([^)]+)\)/ + ); + const alpha = alphaMatch + ? alphaMatch[1] + : "1"; + const baseColorName = + getPaletteName(value); + + return alpha === "1" + ? { + name: `${key}`, + value: baseColorName, + hasAlias: true, + description: baseColorName, + } + : { + name: `${key}`, + value: hexToRgba( + getPaletteValue( + baseColorName + ), + parseFloat(alpha) + ), + hasAlias: false, + description: baseColorName, + }; + } + + throw new Error( + `Unexpected color format for key: ${key}` + ); + } + ); + } + + // Other token processing logic + const paletteArray = Object.keys( + parsedContent.global.palette + ).map((key) => ({ + name: key, + value: hexToRgba( + parsedContent.global.palette[key].value + ), + })); + + const radiusArray = Object.keys( + parsedContent.radius + ).map((key) => ({ + name: `radii/${key}`, + value: + typeof parsedContent.radius[key].value === + "string" + ? parsedContent.radius[key].value === + "circle" + ? 999 // If the value is "circle", set it to 999 + : parseFloat( + parsedContent.radius[key].value + ) // Otherwise, convert it to a float + : parsedContent.radius[key].value, // If it's not a string, use the original value + })); + + const fontWeightArray = Object.keys( + parsedContent.text.weight + ).map((key) => ({ + name: `fontWeight/${key}`, + value: parsedContent.text.weight[key].value, + })); + + const fontSizeArray = Object.keys( + parsedContent.text.size + ).flatMap((key) => { + const value = + parsedContent.text.size[key].value; + + // Check if the value is an object with mobile and desktop properties + if ( + typeof value === "object" && + value !== null + ) { + return [ + { + name: `fontSize/mobile/${key}`, + value: parseFloat(value.mobile), + }, + { + name: `fontSize/desktop/${key}`, + value: parseFloat(value.desktop), + }, + ]; + } + + // If value is not an object, return a single entry + return { + name: key, + value: parseFloat(value), + }; + }); + + const lineHeightArray = Object.keys( + parsedContent.text.lineHeight + ).flatMap((key) => { + const value = + parsedContent.text.lineHeight[key].value; + + // Check if the value is an object with mobile and desktop properties + if ( + typeof value === "object" && + value !== null + ) { + return [ + { + name: `lineHeight/mobile/${key}`, + value: parseFloat(value.mobile), + }, + { + name: `lineHeight/desktop/${key}`, + value: parseFloat(value.desktop), + }, + ]; + } + + // If value is not an object, return a single entry + return { + name: key, + value: parseFloat(value), + }; + }); + + const themeVariantArray = Object.keys( + parsedContent.themeVariant + ).map((key) => ({ + name: `themeVariant/${key}`, + value: + parsedContent.themeVariant[key].value, + })); + + // Accumulate results + accumulator[fileName] = { + light: processColors( + parsedContent, + "light", + allParsedContent + ), + dark: processColors( + parsedContent, + "dark", + allParsedContent + ), + palette: paletteArray, + radius: radiusArray, + fontWeight: fontWeightArray, + fontSize: fontSizeArray, + lineHeight: lineHeightArray, + themeVariant: themeVariantArray, + }; + + return accumulator; + }, {}); +}; diff --git a/tokens/figma/utils/figma-utils.mjs b/tokens/figma/utils/figma-utils.mjs new file mode 100644 index 0000000000..cf3040d5e6 --- /dev/null +++ b/tokens/figma/utils/figma-utils.mjs @@ -0,0 +1,294 @@ +import { MODE_NAMES } from "./constants.mjs"; +import { getFigmaData } from "./api-request.mjs"; + +export function generateTempModeId( + targetMode, + targetCollection +) { + return `tempId_${targetCollection}_${targetMode}`; +} + +export function hasDefaultMode( + targetCollectionName, + existingCollections +) { + const collection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === targetCollectionName + ); + + if (!collection) { + console.warn( + `Collection ${targetCollectionName} not found.` + ); + return false; + } + + const existingModes = collection.modes || []; + + // Return true if a mode named "Default" exists, otherwise false + return existingModes.some( + (m) => m.name === MODE_NAMES.DEFAULT + ); +} + +export async function updateCollections( + collections, + FILE_KEY +) { + try { + const figmaData = await getFigmaData( + FILE_KEY + ); + + const newData = { + variableCollections: [], + }; + + const existingCollections = + figmaData.meta.variableCollections; + + function generateTempId(name) { + return `tempId_${name}`; + } + + function updateCollection( + collectionName, + existingCollections + ) { + // Find the existing collection by name + const existingCollection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === collectionName + ); + + if (existingCollection) { + // If the collection exists, update it + newData.variableCollections.push({ + action: "UPDATE", + id: existingCollection.id, + name: collectionName, + }); + } else { + // If the collection doesn't exist, create it + const tempId = generateTempId( + collectionName + ); + newData.variableCollections.push({ + action: "CREATE", + id: tempId, + name: collectionName, + }); + } + } + + // Process each collection name + collections.forEach((collection) => { + updateCollection( + collection, + existingCollections + ); + }); + + // Return the processed data for further use + return newData; + } catch (error) { + console.error("Error:", error); + throw error; // rethrow the error to be handled later + } +} + +export async function updateOrCreateModes({ + mode, + isDefault, + targetCollectionName, + existingCollections, +}) { + const collection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === targetCollectionName + ); + + // Handle the case when the collection is not found + if (!collection) { + console.warn( + `Collection ${targetCollectionName} not found.` + ); + return null; + } + + const collectionId = collection.id; + const existingModes = collection.modes || []; + + // Look for the existing mode by name and the default mode + const existingMode = existingModes.find( + (m) => m.name === mode.name + ); + const defaultMode = existingModes.find( + (m) => m.name === MODE_NAMES.DEFAULT + ); + + // If it's the default mode, update or rename it + if (isDefault && defaultMode) { + return { + action: "UPDATE", + id: defaultMode.modeId, + name: mode.name, // Rename or update "Default" mode to the target name + variableCollectionId: collectionId, + }; + } + + // If the mode does not exist, create it + if (!existingMode) { + return { + action: "CREATE", + id: generateTempModeId( + mode.name, + targetCollectionName + ), + name: mode.name, // Create the mode with the target name + variableCollectionId: collectionId, + }; + } + + // If the mode exists, update it + return { + action: "UPDATE", + id: existingMode.modeId, + name: mode.name, // Update the mode with the correct name + variableCollectionId: collectionId, + }; +} + +export async function updateOrCreateVariables({ + variable, + targetCollectionName, + existingVariables, + existingCollections, +}) { + const collectionId = Object.values( + existingCollections + ).find( + (collection) => + collection.name === targetCollectionName + ).id; + + //If exists retrieve the variable id + const existingVariable = Object.values( + existingVariables + ).find( + (v) => + v.name === variable.name && + v.variableCollectionId === collectionId + ); + + const tempId = `tempId_${targetCollectionName}_${variable.name}`; + + //Retrieve the variableCollectionId with the targetCollectionName + + if (!existingVariable) { + // Create new variable + return { + action: "CREATE", + id: tempId, + name: variable.name, + variableCollectionId: collectionId, + resolvedType: variable.resolvedType, + scopes: variable.scopes, + }; + } else { + // Update existing variable + return { + action: "UPDATE", + id: existingVariable.id, + name: variable.name, + variableCollectionId: collectionId, + resolvedType: variable.resolvedType, + scopes: variable.scopes, + }; + } +} + +export async function updateOrCreateVariableModeValues({ + variable, + targetModeName, + targetCollectionName, + existingCollections, + existingVariables, +}) { + // Find the mode for the given modeName, or use tempId if mode is being created + + const targetCollection = Object.values( + existingCollections + ).find( + (collection) => + collection.name === targetCollectionName + ); + + if (!targetCollection) { + console.warn( + `Collection ${targetCollectionName} not found.` + ); + return; + } + + // Now access the modes from the found collection + const existingModes = + targetCollection.modes.find( + (m) => m.name === targetModeName + ); + + const modeId = existingModes + ? existingModes.modeId + : generateTempModeId( + targetModeName, + targetCollectionName + ); + + if (!modeId) { + console.warn( + `Mode ${targetModeName} not found and no tempId provided.` + ); + return; + } + + const collectionId = Object.values( + existingCollections + ).find( + (collection) => + collection.name === targetCollectionName + )?.id; + + // Retrieve the variable id if exists + const existingVariable = Object.values( + existingVariables + ).find( + (v) => + v.name === variable.name && + v.variableCollectionId === collectionId + ); + + const tempId = `tempId_${targetCollectionName}_${variable.name}`; + + return { + action: existingVariable + ? "UPDATE" + : "CREATE", + variableId: existingVariable + ? existingVariable.id + : tempId, // Use existing variable ID or a temp one + modeId: modeId, + value: variable.hasAlias + ? { + type: "VARIABLE_ALIAS", + id: variable.value, + } + : variable.value, + }; +} diff --git a/tokens/figma/utils/format-brand-name.mjs b/tokens/figma/utils/format-brand-name.mjs new file mode 100644 index 0000000000..f9fae2d892 --- /dev/null +++ b/tokens/figma/utils/format-brand-name.mjs @@ -0,0 +1,21 @@ +function formatBrandName(brand) { + // Check if the brand is "tu" and return it in uppercase + if (brand === "tu") { + return brand.toUpperCase(); + } + + // Check if the brand is telefonica and return it as sentence case and with an accent + if (brand === "telefonica") { + return "Telefónica"; + } + + // For other brands, remove the hyphen and convert to sentence case + return brand + .replace(/-/g, " ") // Remove hyphens and replace with spaces + .toLowerCase() // Convert all to lowercase first + .replace(/\b\w/g, (char) => + char.toUpperCase() + ); // Capitalize the first letter of each word +} + +export default formatBrandName; diff --git a/tokens/figma/utils/hex-to-rgba.mjs b/tokens/figma/utils/hex-to-rgba.mjs new file mode 100644 index 0000000000..1e9f533d4c --- /dev/null +++ b/tokens/figma/utils/hex-to-rgba.mjs @@ -0,0 +1,28 @@ +export function hexToRgba(hex, alpha = 1) { + // Remove the leading # if it's present + hex = hex.replace(/^#/, ""); + + // Expand shorthand form (e.g., "03F") to full form (e.g., "0033FF") + if (hex.length === 3) { + hex = hex + .split("") + .map((char) => char + char) + .join(""); + } + + // Parse the r, g, b values + const bigint = parseInt(hex, 16); + const r = ((bigint >> 16) & 255) / 255; + const g = ((bigint >> 8) & 255) / 255; + const b = (bigint & 255) / 255; + + // Return the RGBA object with normalized values + return { + r, + g, + b, + a: alpha, + }; +} + +export default hexToRgba; diff --git a/tokens/figma/variables.mjs b/tokens/figma/variables.mjs new file mode 100644 index 0000000000..8fa71939f0 --- /dev/null +++ b/tokens/figma/variables.mjs @@ -0,0 +1,153 @@ +import { + BRANDS, + COLLECTION_NAMES, + VARIABLE_TYPES, + VARIABLE_SCOPES, +} from "./utils/constants.mjs"; + +import formatBrandName from "./utils/format-brand-name.mjs"; + +export const FONT_FAMILIES = { + [BRANDS.MOVISTAR]: "On Air", + [BRANDS.VIVO_NEW]: "Vivo Type", + [BRANDS.O2_NEW]: "On Air", + [BRANDS.TELEFONICA]: "Telefonica Sans", + [BRANDS.BLAU]: "SF Pro Text", + [BRANDS.TU]: "Telefonica Sans", +}; + +export const ICON_SETS = { + [BRANDS.MOVISTAR]: "Default", + [BRANDS.VIVO_NEW]: "Vivo", + [BRANDS.O2_NEW]: "O2", + [BRANDS.TELEFONICA]: "Default", + [BRANDS.BLAU]: "Blau", + [BRANDS.TU]: "Default", +}; + +export const BRAND_NAMES = { + [BRANDS.MOVISTAR]: "Movistar", + [BRANDS.VIVO_NEW]: "Vivo", + [BRANDS.O2_NEW]: "O2", + [BRANDS.TELEFONICA]: "Telefónica", + [BRANDS.BLAU]: "Blau", + [BRANDS.TU]: "TU", +}; + +export const getPaletteVariables = ( + jsonData, + brand +) => [ + { + variables: jsonData[brand]?.palette || [], + collectionName: COLLECTION_NAMES.PALETTE, + resolvedType: VARIABLE_TYPES.COLOR, + variableScopes: [VARIABLE_SCOPES.ALL_SCOPES], + hasAlias: false, + }, +]; + +export const getConstantVariables = ( + jsonData, + brand +) => [ + { + variables: jsonData[brand]?.light || [], + collectionName: COLLECTION_NAMES.COLOR_SCHEME, + resolvedType: VARIABLE_TYPES.COLOR, + variableScopes: [VARIABLE_SCOPES.ALL_SCOPES], + hasAlias: false, + }, + { + variables: jsonData[brand]?.dark || [], + collectionName: COLLECTION_NAMES.COLOR_SCHEME, + resolvedType: VARIABLE_TYPES.COLOR, + variableScopes: [VARIABLE_SCOPES.ALL_SCOPES], + hasAlias: false, + }, +]; + +export const getNonColorVariables = ( + jsonData, + brand +) => [ + { + variables: jsonData[brand]?.radius || [], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.FLOAT, + variableScopes: [ + VARIABLE_SCOPES.CORNER_RADIUS, + VARIABLE_SCOPES.TEXT_CONTENT, + ], + hasAlias: false, + }, + { + variables: jsonData[brand]?.fontWeight || [], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.STRING, + variableScopes: [ + VARIABLE_SCOPES.FONT_WEIGHT, + VARIABLE_SCOPES.TEXT_CONTENT, + ], + hasAlias: false, + }, + { + variables: jsonData[brand]?.fontSize || [], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.FLOAT, + variableScopes: [ + VARIABLE_SCOPES.FONT_SIZE, + VARIABLE_SCOPES.TEXT_CONTENT, + ], + hasAlias: false, + }, + { + variables: jsonData[brand]?.lineHeight || [], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.FLOAT, + variableScopes: [ + VARIABLE_SCOPES.LINE_HEIGHT, + VARIABLE_SCOPES.TEXT_CONTENT, + ], + hasAlias: false, + }, + { + variables: + jsonData[brand]?.themeVariant || [], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.STRING, + variableScopes: [VARIABLE_SCOPES.ALL_SCOPES], + hasAlias: false, + }, + { + variables: [ + { + name: "fontFamily/fontFamily", + value: FONT_FAMILIES[brand], + }, + ], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.STRING, + variableScopes: [ + VARIABLE_SCOPES.FONT_FAMILY, + VARIABLE_SCOPES.TEXT_CONTENT, + ], + hasAlias: false, + }, + { + variables: [ + { + name: "utils/iconSet", + value: ICON_SETS[brand], + }, + { + name: "utils/brandName", + value: BRAND_NAMES[brand], + }, + ], + collectionName: COLLECTION_NAMES.SKIN, + resolvedType: VARIABLE_TYPES.STRING, + variableScopes: [VARIABLE_SCOPES.ALL_SCOPES], + hasAlias: false, + }, +]; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000000..d850429f57 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,47 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==