diff --git a/exporters/variables-scss/src/formatters/__fixtures__/exampleFileContent.scss b/exporters/variables-scss/src/formatters/__fixtures__/exampleFileContent.scss index 1e218e3320..cca1823da7 100644 --- a/exporters/variables-scss/src/formatters/__fixtures__/exampleFileContent.scss +++ b/exporters/variables-scss/src/formatters/__fixtures__/exampleFileContent.scss @@ -3,10 +3,9 @@ $grid-spacing-desktop: 32px !default; $grid-columns: 12 !default; -$grid-spacings: ( - spacing-desktop: $grid-spacing-desktop, -) !default; - $grids: ( + spacing: ( + desktop: $grid-spacing-desktop, + ), columns: $grid-columns, ) !default; diff --git a/exporters/variables-scss/src/formatters/__fixtures__/mockedExampleTokens.ts b/exporters/variables-scss/src/formatters/__fixtures__/mockedExampleTokens.ts index 95cffab0cf..9b7c1d4f80 100644 --- a/exporters/variables-scss/src/formatters/__fixtures__/mockedExampleTokens.ts +++ b/exporters/variables-scss/src/formatters/__fixtures__/mockedExampleTokens.ts @@ -1,4 +1,5 @@ import { + ColorToken, DimensionToken, DimensionTokenValue, StringToken, @@ -122,3 +123,108 @@ export const exampleMockedGroups: TokenGroup[] = [ updatedAt: null, }, ]; + +export const exampleMockedColorsTokens = new Map(); +exampleMockedColorsTokens.set('actionColorRef', { + id: 'actionColorRef', + name: 'active', + tokenType: TokenType.color, + parentGroupId: '1', + origin: { + name: 'action/button/primary/default', + }, + value: { + color: { + r: 202, + g: 32, + b: 38, + referencedTokenId: null, + }, + opacity: { + unit: 'Raw', + measure: 1, + referencedTokenId: null, + }, + referencedTokenId: null, + }, +} as ColorToken); +exampleMockedColorsTokens.set('backgroundColorRef', { + id: 'backgroundColorRef', + name: 'primary', + tokenType: TokenType.color, + parentGroupId: '2', + origin: { + name: 'background/primary', + }, + value: { + color: { + r: 255, + g: 255, + b: 255, + referencedTokenId: null, + }, + opacity: { + unit: 'Raw', + measure: 1, + referencedTokenId: null, + }, + referencedTokenId: null, + }, +} as ColorToken); + +export const exampleMockedColorGroups: TokenGroup[] = [ + { + ...groupFunctions, + id: '1', + idInVersion: 'idInVersionValue', + brandId: 'brandIdValue', + designSystemVersionId: 'designSystemVersionIdValue', + name: 'primary', + description: '', + isRoot: false, + tokenType: TokenType.color, + childrenIds: ['actionColorRef'], + path: ['action', 'button'], + tokenIds: ['actionColorRef'], + subgroupIds: [], + parentGroupId: 'parent1', + sortOrder: -1, + createdAt: null, + updatedAt: null, + }, + { + ...groupFunctions, + id: '2', + idInVersion: 'idInVersionValue', + brandId: 'brandIdValue', + designSystemVersionId: 'designSystemVersionIdValue', + name: 'background', + description: '', + isRoot: false, + tokenType: TokenType.color, + childrenIds: ['backgroundColorRef'], + path: [], + tokenIds: ['backgroundColorRef'], + subgroupIds: [], + parentGroupId: 'parent2', + sortOrder: -1, + createdAt: null, + updatedAt: null, + }, +]; + +export const exampleMockedInvariantTokens = new Map(); +exampleMockedInvariantTokens.set('radiiRef', { + id: 'radiiRef', + name: 'radius-full', + tokenType: TokenType.dimension, + parentGroupId: '1', + origin: { + name: 'Radius/radius-full', + }, + value: { + unit: 'Pixels', + measure: 9999, + referencedTokenId: null, + }, +} as DimensionToken); diff --git a/exporters/variables-scss/src/formatters/cssFormatter.ts b/exporters/variables-scss/src/formatters/cssFormatter.ts index b68a5b1988..714d945394 100644 --- a/exporters/variables-scss/src/formatters/cssFormatter.ts +++ b/exporters/variables-scss/src/formatters/cssFormatter.ts @@ -16,8 +16,8 @@ export const formatCSS = (css: string): string => { for (const line of lines) { if (line.includes('(')) { + formattedCSS += `${IDENTATION.repeat(indentationLevel)}${line}\n`; indentationLevel += 1; - formattedCSS += `${line}\n`; } else if (line.includes(')')) { indentationLevel -= 1; formattedCSS += `${IDENTATION.repeat(indentationLevel)}${line}\n`; diff --git a/exporters/variables-scss/src/generators/__tests__/contentGenerator.test.ts b/exporters/variables-scss/src/generators/__tests__/contentGenerator.test.ts index 6eef693383..e6f30a179b 100644 --- a/exporters/variables-scss/src/generators/__tests__/contentGenerator.test.ts +++ b/exporters/variables-scss/src/generators/__tests__/contentGenerator.test.ts @@ -19,6 +19,7 @@ describe('contentGenerator', () => { const groupNames = ['Grid', 'String']; const withCssObject = true; const hasParentPrefix = true; + const sortByNumValue = false; const fileContent = generateFileContent( tokens, @@ -28,6 +29,7 @@ describe('contentGenerator', () => { groupNames, withCssObject, hasParentPrefix, + sortByNumValue, ); expect(fileContent).toStrictEqual({ content: mockedExpectedResult }); diff --git a/exporters/variables-scss/src/generators/__tests__/cssGenerator.test.ts b/exporters/variables-scss/src/generators/__tests__/cssGenerator.test.ts index 2be51ddc55..fc60d106fb 100644 --- a/exporters/variables-scss/src/generators/__tests__/cssGenerator.test.ts +++ b/exporters/variables-scss/src/generators/__tests__/cssGenerator.test.ts @@ -34,7 +34,7 @@ const dataProvider = [ token: { id: '3', name: 'unsupportedToken', - tokenType: TokenType.color, + tokenType: TokenType.duration, } as Token, expectedCss: null, hasParentPrefix: true, @@ -53,9 +53,16 @@ describe('cssGenerator', () => { describe('generateCssFromTokens', () => { it('should generate CSS from tokens', () => { - const css = generateCssFromTokens(Array.from(exampleMockedTokens.values()), mappedTokens, tokenGroups, true); + const css = generateCssFromTokens( + Array.from(exampleMockedTokens.values()), + mappedTokens, + tokenGroups, + 'Grid', + true, + false, + ); - expect(css).toBe('$grid-spacing-desktop: 32px !default;\n$grid-columns: 12 !default;'); + expect(css).toBe('$grid-columns: 12 !default;\n\n$grid-spacing-desktop: 32px !default;'); }); }); }); diff --git a/exporters/variables-scss/src/generators/__tests__/cssObjectGenerator.test.ts b/exporters/variables-scss/src/generators/__tests__/cssObjectGenerator.test.ts index 52b398f914..f32dc052a8 100644 --- a/exporters/variables-scss/src/generators/__tests__/cssObjectGenerator.test.ts +++ b/exporters/variables-scss/src/generators/__tests__/cssObjectGenerator.test.ts @@ -1,6 +1,18 @@ -import { Token, TokenGroup } from '@supernovaio/sdk-exporters'; -import { generateCssObjectFromTokens, generateObjectContent } from '../cssObjectGenerator'; -import { exampleMockedGroups, exampleMockedTokens } from '../../formatters/__fixtures__/mockedExampleTokens'; +import { Token, TokenGroup, TokenType } from '@supernovaio/sdk-exporters'; +import { + createGlobalColorsObject, + createObjectStructureFromTokenNameParts, + generateCssObjectFromTokens, + getTokenAlias, + normalizeFirstNamePart, +} from '../cssObjectGenerator'; +import { + exampleMockedColorGroups, + exampleMockedColorsTokens, + exampleMockedGroups, + exampleMockedInvariantTokens, + exampleMockedTokens, +} from '../../formatters/__fixtures__/mockedExampleTokens'; const mappedTokens: Map = new Map([]); const tokenGroups: Array = exampleMockedGroups; @@ -15,21 +27,88 @@ describe('cssObjectGenerator', () => { true, ); - expect(css).toBe( - '$grid-spacings: (\nspacing-desktop: $grid-spacing-desktop,\n) !default;\n\n$grids: (\ncolumns: $grid-columns,\n) !default;\n\n', + expect(css).toStrictEqual({ + $grids: { columns: '$grid-columns', spacing: { desktop: '$grid-spacing-desktop' } }, + }); + }); + + it('should generate CSS object from tokens with colors', () => { + const css = generateCssObjectFromTokens( + Array.from(exampleMockedColorsTokens.values()), + mappedTokens, + exampleMockedColorGroups, + true, ); + + expect(css).toStrictEqual({ + '$action-colors': { + button: { + primary: { + active: '$action-button-primary-active', + }, + }, + }, + '$background-colors': { + primary: '$background-primary', + }, + $colors: { + action: '$action-colors', + background: '$background-colors', + }, + }); }); }); - describe('generateObjectContent', () => { - it('should generate object content', () => { - const objectContent = generateObjectContent( - [exampleMockedTokens.get('dimensionRef') as Token], + describe('createObjectStructureFromTokenNameParts', () => { + it('should create object structure from token name parts', () => { + const cssObject = createObjectStructureFromTokenNameParts( + exampleMockedTokens.get('dimensionRef') as Token, tokenGroups, true, + { $grids: { columns: '$grid-columns' } }, ); - expect(objectContent).toBe('spacing-desktop: $grid-spacing-desktop,\n'); + expect(cssObject).toStrictEqual({ + $grids: { columns: '$grid-columns', spacing: { desktop: '$grid-spacing-desktop' } }, + }); + }); + }); + + describe('handleInvariantTokens', () => { + it('should return token alias for invariant case', () => { + const token = exampleMockedInvariantTokens.get('radiiRef') as Token; + expect(getTokenAlias(token)).toBe('full'); + }); + + it('should return token alias for non-invariant case', () => { + const token = exampleMockedTokens.get('dimensionRef') as Token; + expect(getTokenAlias(token)).toBe('desktop'); + }); + }); + + describe('getTokenAlias', () => { + it('should return token alias for non-numeric', () => { + const token = exampleMockedTokens.get('dimensionRef') as Token; + expect(getTokenAlias(token)).toBe('desktop'); + }); + }); + + describe('normalizeFirstNamePart', () => { + it('should return correct first part name for token type dimension', () => { + expect(normalizeFirstNamePart('grid', TokenType.dimension)).toBe('$grids'); + }); + + it('should return correct first part name for token type color', () => { + expect(normalizeFirstNamePart('action', TokenType.color)).toBe('$action-colors'); + }); + }); + + describe('createGlobalColorsObject', () => { + it('should create global colors object', () => { + const colorKeys = ['$action-colors', '$background-colors']; + const colorsObject = createGlobalColorsObject(colorKeys); + + expect(colorsObject).toStrictEqual({ action: '$action-colors', background: '$background-colors' }); }); }); }); diff --git a/exporters/variables-scss/src/generators/__tests__/fileGenerator.test.ts b/exporters/variables-scss/src/generators/__tests__/fileGenerator.test.ts index 62cb276428..f40e054881 100644 --- a/exporters/variables-scss/src/generators/__tests__/fileGenerator.test.ts +++ b/exporters/variables-scss/src/generators/__tests__/fileGenerator.test.ts @@ -25,6 +25,7 @@ describe('fileGenerator', () => { { fileName: '_other.scss', content: mockedExpectedResult }, { fileName: '_radii.scss', content: emptyFile }, { fileName: '_spacing.scss', content: emptyFile }, + { fileName: '_colors.scss', content: emptyFile }, ]); }); }); diff --git a/exporters/variables-scss/src/generators/contentGenerator.ts b/exporters/variables-scss/src/generators/contentGenerator.ts index cf97f4094b..0037369fa1 100644 --- a/exporters/variables-scss/src/generators/contentGenerator.ts +++ b/exporters/variables-scss/src/generators/contentGenerator.ts @@ -1,7 +1,8 @@ import { Token, TokenGroup, TokenType } from '@supernovaio/sdk-exporters'; import { generateCssFromTokens } from './cssGenerator'; -import { generateCssObjectFromTokens } from './cssObjectGenerator'; +import { CssObjectType, generateCssObjectFromTokens } from './cssObjectGenerator'; import { formatCSS } from '../formatters/cssFormatter'; +import { convertToScss, deepMergeObjects } from '../helpers/cssObjectHelper'; // Add disclaimer to the top of the content export const addDisclaimer = (content: string): string => { @@ -12,6 +13,7 @@ export const filterTokensByTypeAndGroup = (tokens: Token[], type: TokenType, gro return tokens.filter((token) => token.tokenType === type && token.origin?.name?.includes(group)); }; +// TODO: refactor to use fileData instead of destructuring export const generateFileContent = ( tokens: Token[], mappedTokens: Map, @@ -20,9 +22,10 @@ export const generateFileContent = ( groupNames: string[], withCssObject: boolean, hasParentPrefix: boolean, + sortByNumValue: boolean, ) => { let cssTokens = ''; - let cssObject = ''; + let cssObject: CssObjectType = {}; // Iterate over token types and group names to filter tokens tokenTypes.forEach((tokenType) => { @@ -30,15 +33,30 @@ export const generateFileContent = ( const filteredTokens = filterTokensByTypeAndGroup(tokens, tokenType, group); // Generate css tokens - cssTokens += generateCssFromTokens(filteredTokens, mappedTokens, tokenGroups, hasParentPrefix); + cssTokens += generateCssFromTokens( + filteredTokens, + mappedTokens, + tokenGroups, + group, + hasParentPrefix, + sortByNumValue, + ); cssTokens += '\n\n'; - // Generate css object - cssObject += generateCssObjectFromTokens(filteredTokens, mappedTokens, tokenGroups, hasParentPrefix); + // Generate css object and merge it with the existing one + const groupCssObject = generateCssObjectFromTokens(filteredTokens, mappedTokens, tokenGroups, hasParentPrefix); + cssObject = deepMergeObjects(cssObject, groupCssObject); }); }); - const content = withCssObject ? `${cssTokens}${cssObject}` : cssTokens; + let content = cssTokens; + + // convert css object to scss structure + if (withCssObject) { + content += Object.entries(cssObject) + .map(([key, obj]) => `${key}: (\n${convertToScss(obj as CssObjectType)}\n) !default;\n\n`) + .join(''); + } return { content: addDisclaimer(formatCSS(content)), diff --git a/exporters/variables-scss/src/generators/cssGenerator.ts b/exporters/variables-scss/src/generators/cssGenerator.ts index 87ef40a599..463e824f07 100644 --- a/exporters/variables-scss/src/generators/cssGenerator.ts +++ b/exporters/variables-scss/src/generators/cssGenerator.ts @@ -1,7 +1,8 @@ -import { DimensionToken, StringToken, Token, TokenGroup, TokenType } from '@supernovaio/sdk-exporters'; -import { CSSHelper } from '@supernovaio/export-helpers'; -import { formatTokenName, tokenVariableName } from '../helpers/tokenHelper'; +import { ColorToken, DimensionToken, StringToken, Token, TokenGroup, TokenType } from '@supernovaio/sdk-exporters'; +import { ColorFormat, CSSHelper } from '@supernovaio/export-helpers'; +import { addEmptyLineBetweenTokenGroups, formatTokenName, sortTokens, tokenVariableName } from '../helpers/tokenHelper'; import { handleSpecialCase } from '../helpers/specialCaseHelper'; +import { normalizeColor } from '../helpers/colorHelper'; export const tokenToCSSByType = ( token: Token, @@ -28,6 +29,21 @@ export const tokenToCSSByType = ( return formatTokenName(name, value); } + if (token.tokenType === TokenType.color) { + const colorToken = token as ColorToken; + const name = tokenVariableName(colorToken, tokenGroups, withParent); + let value = CSSHelper.colorTokenValueToCSS(colorToken.value, mappedTokens, { + allowReferences: true, + decimals: 3, + colorFormat: ColorFormat.hex8, + tokenToVariableRef: () => '', + }); + value = normalizeColor(value); + value = handleSpecialCase(name, value); + + return formatTokenName(name, value); + } + return null; }; @@ -35,10 +51,16 @@ export const generateCssFromTokens = ( tokens: Token[], mappedTokens: Map, tokenGroups: Array, + group: string, hasParentPrefix: boolean, + sortByNumValue: boolean, ): string => { - return tokens - .map((token) => tokenToCSSByType(token, mappedTokens, tokenGroups, hasParentPrefix)) - .filter(Boolean) - .join('\n'); + const sortedTokens = sortTokens(tokens, tokenGroups, hasParentPrefix, group, sortByNumValue); + + const cssTokens = sortedTokens.map((token) => ({ + css: tokenToCSSByType(token, mappedTokens, tokenGroups, hasParentPrefix), + parentGroupId: token.parentGroupId, + })); + + return addEmptyLineBetweenTokenGroups(cssTokens); }; diff --git a/exporters/variables-scss/src/generators/cssObjectGenerator.ts b/exporters/variables-scss/src/generators/cssObjectGenerator.ts index a29dcb0a5e..67cec26011 100644 --- a/exporters/variables-scss/src/generators/cssObjectGenerator.ts +++ b/exporters/variables-scss/src/generators/cssObjectGenerator.ts @@ -1,51 +1,121 @@ -import { Token, TokenGroup } from '@supernovaio/sdk-exporters'; -import { toPlural } from '../helpers/stringHelper'; +import { Token, TokenGroup, TokenType } from '@supernovaio/sdk-exporters'; import { tokenVariableName } from '../helpers/tokenHelper'; +import { toPlural } from '../helpers/stringHelper'; -export const generateObjectContent = ( - tokens: Array, +export const COLOR_SUFFIX = '-colors'; + +export type CssObjectType = { [key: string]: string | object }; + +/* This function handles cases that are outside the logic of aliases for the remaining tokens. +A common condition is that for tokens with a numeric part, the non-numeric part is dropped. +Non-numeric tokens are left in their original form. Deviations from this logic are addressed here. +e.g. radius-full -> full */ +const invariantTokenAlias: { [key: string]: string } = { + 'radius-full': 'full', +}; + +export const normalizeFirstNamePart = (part: string, tokenType: TokenType): string => { + if (tokenType === TokenType.color) { + return `$${part}${COLOR_SUFFIX}`; + } + + return `$${toPlural(part.toLowerCase())}`; +}; + +export const handleInvariantTokenAlias = (tokenName: string): string => { + if (invariantTokenAlias[tokenName]) { + return invariantTokenAlias[tokenName]; + } + + return tokenName; +}; + +export const getTokenAlias = (token: Token): string => { + let alias; + const numericPart = token.name.match(/\d+/)?.[0]; + const nonNumericPart = handleInvariantTokenAlias(token.name.toLowerCase()); + + if (token.tokenType !== TokenType.color && numericPart) { + alias = numericPart; + } else { + alias = nonNumericPart; + } + + return alias; +}; + +export const createObjectStructureFromTokenNameParts = ( + token: Token, tokenGroups: Array, - withParent: boolean, -): string => { - return tokens.reduce((result, token) => { - const name = tokenVariableName(token, tokenGroups, withParent); - const numericPart = name.match(/\d+/)?.[0]; - const prefix = `${token.origin?.name?.split('/')[0].toLowerCase()}-`; - const nonNumericPart = name.replace(prefix, ''); - - if (numericPart) { - result += `${numericPart}: $${name},\n`; - } else if (nonNumericPart) { - result += `${nonNumericPart}: $${name},\n`; - } - - return result; - }, ''); + hasParentPrefix: boolean, + cssObjectRef: CssObjectType, +): CssObjectType => { + let currentObject: CssObjectType = cssObjectRef; + + const tokenNameParts = token.origin?.name?.split('/'); + + if (tokenNameParts) { + tokenNameParts.forEach((part, index) => { + let modifiedPart = part; + + // format first part of the name part as object key + if (index === 0) { + modifiedPart = normalizeFirstNamePart(part, token.tokenType); + } + // format the last part of the name part as token alias and assign token value + if (index === tokenNameParts.length - 1) { + const tokenValue = `$${tokenVariableName(token, tokenGroups, hasParentPrefix)}`; + const tokenAlias = getTokenAlias(token); + + currentObject[tokenAlias] = tokenValue; + } else { + // format the rest of the name parts as object keys + currentObject[modifiedPart] = currentObject[modifiedPart] || {}; + currentObject = currentObject[modifiedPart] as CssObjectType; + } + }); + } + + return cssObjectRef; }; +export const createGlobalColorsObject = (colorKeys: Array): object => { + return colorKeys.reduce((acc, key) => { + // remove '-colors' suffix and '$' prefix from the key + const colorAlias = key.slice(0, -7).replace('$', ''); + + return { ...acc, [colorAlias]: key }; + }, {}); +}; + +// TODO: refactor this function to not use cssObject refence export const generateCssObjectFromTokens = ( tokens: Array, mappedTokens: Map, tokenGroups: Array, hasParentPrefix: boolean, -): string => { - const originNameMap = new Map>(); - tokens.forEach((token) => { - const originName = token.origin?.name; - - if (originName) { - const nameParts = originName.split('/'); - nameParts.pop(); - const objectName = toPlural(nameParts.join('-').toLowerCase()); - originNameMap.set(objectName, [...(originNameMap.get(objectName) || []), token]); - } - }); - - return Array.from(originNameMap.entries()) - .map(([objectName, token]) => { - const objectContent = generateObjectContent(token, tokenGroups, hasParentPrefix); - - return objectContent.trim() && `$${objectName}: (\n${objectContent}) !default;\n\n`; - }) - .join(''); +): CssObjectType => { + const cssObject = tokens.reduce((cssObjectAccumulator, token) => { + const currentObject = createObjectStructureFromTokenNameParts( + token, + tokenGroups, + hasParentPrefix, + cssObjectAccumulator, + ); + + return { ...cssObjectAccumulator, ...currentObject }; + }, {}); + + // check if there are any color keys in the object + const colorKeys = Object.keys(cssObject).filter((key) => key.endsWith(COLOR_SUFFIX)); + + /* if there are color keys, create a separate global object for + all colors keys and place it at the end of the file */ + if (colorKeys.length > 0) { + const colorsObject = createGlobalColorsObject(colorKeys); + + return { ...cssObject, $colors: colorsObject }; + } + + return cssObject; }; diff --git a/exporters/variables-scss/src/generators/fileGenerator.ts b/exporters/variables-scss/src/generators/fileGenerator.ts index 7ae08d9d25..05657170bc 100644 --- a/exporters/variables-scss/src/generators/fileGenerator.ts +++ b/exporters/variables-scss/src/generators/fileGenerator.ts @@ -1,34 +1,46 @@ import { TokenGroup, Token, TokenType } from '@supernovaio/sdk-exporters'; import { generateFileContent } from './contentGenerator'; -const filesData = [ +export type FileData = { + fileName: string; + tokenTypes: TokenType[]; + groupNames: string[]; + withCssObject?: boolean; + hasParentPrefix?: boolean; + sortByNumValue?: boolean; +}; + +const filesData: FileData[] = [ { fileName: '_borders.scss', tokenTypes: [TokenType.dimension], groupNames: ['Border'], withCssObject: false, - hasParentPrefix: true, + sortByNumValue: true, }, { fileName: '_other.scss', tokenTypes: [TokenType.dimension, TokenType.string], groupNames: ['Grid', 'Container', 'Breakpoint'], - withCssObject: true, - hasParentPrefix: true, }, { fileName: '_radii.scss', tokenTypes: [TokenType.dimension], groupNames: ['Radius'], - withCssObject: true, hasParentPrefix: false, + sortByNumValue: true, }, { fileName: '_spacing.scss', tokenTypes: [TokenType.dimension], groupNames: ['Spacing'], - withCssObject: true, hasParentPrefix: false, + sortByNumValue: true, + }, + { + fileName: '_colors.scss', + tokenTypes: [TokenType.color], + groupNames: [''], }, ]; @@ -37,20 +49,24 @@ export const generateFiles = ( mappedTokens: Map, tokenGroups: Array, ) => { - return filesData.map(({ fileName, tokenTypes, groupNames, withCssObject, hasParentPrefix }) => { - const fileContent = generateFileContent( - tokens, - mappedTokens, - tokenGroups, - tokenTypes, - groupNames, - withCssObject, - hasParentPrefix, - ); + return filesData.map( + // TODO: refactor this to use fileData instead of destructuring + ({ fileName, tokenTypes, groupNames, withCssObject = true, hasParentPrefix = true, sortByNumValue = false }) => { + const fileContent = generateFileContent( + tokens, + mappedTokens, + tokenGroups, + tokenTypes, + groupNames, + withCssObject, + hasParentPrefix, + sortByNumValue, + ); - return { - fileName, - ...fileContent, - }; - }); + return { + fileName, + ...fileContent, + }; + }, + ); }; diff --git a/exporters/variables-scss/src/helpers/__tests__/colorHelper.test.ts b/exporters/variables-scss/src/helpers/__tests__/colorHelper.test.ts new file mode 100644 index 0000000000..c3a1fb5760 --- /dev/null +++ b/exporters/variables-scss/src/helpers/__tests__/colorHelper.test.ts @@ -0,0 +1,78 @@ +import { canHexBeShortened, normalizeColor, removeAlphaChannel, shortenHex } from '../colorHelper'; + +const dataProviderItems = [ + { + originalColor: 'ffffffff', + expectedColor: '#fff', + }, + { + originalColor: '123456', + expectedColor: '#123456', + }, + { + originalColor: '123', + expectedColor: '#123', + }, + { + originalColor: 'fff', + expectedColor: '#fff', + }, + { + originalColor: 'ffffff', + expectedColor: '#fff', + }, + { + originalColor: 'ffffff00', + expectedColor: '#fff0', + }, + { + originalColor: 'fffffff0', + expectedColor: '#fffffff0', + }, + { + originalColor: '96969', + expectedColor: '#96969', + }, + { + originalColor: '835ea1', + expectedColor: '#835ea1', + }, + { + originalColor: '00000040', + expectedColor: '#00000040', + }, +]; + +describe('colorHelper', () => { + describe.each(dataProviderItems)('normalizeColor', ({ originalColor, expectedColor }) => { + it('should normalize color', () => { + expect(normalizeColor(originalColor)).toBe(expectedColor); + }); + }); + + describe('canHexBeShortened', () => { + it('should return true if hex can be shortened', () => { + expect(canHexBeShortened('ffffff')).toBe(true); + }); + + it('should return false if hex cannot be shortened', () => { + expect(canHexBeShortened('00000040')).toBe(false); + }); + }); + + describe('shortHex', () => { + it('should shorten hex', () => { + expect(shortenHex('ffffff')).toBe('fff'); + }); + }); + + describe('removeAlphaChannel', () => { + it('should not remove alpha channel', () => { + expect(removeAlphaChannel('ffffff40')).toBe('ffffff40'); + }); + + it('should remove alpha channel in short form', () => { + expect(removeAlphaChannel('fff')).toBe('fff'); + }); + }); +}); diff --git a/exporters/variables-scss/src/helpers/__tests__/cssObjectHelper.test.ts b/exporters/variables-scss/src/helpers/__tests__/cssObjectHelper.test.ts new file mode 100644 index 0000000000..78e8f1520b --- /dev/null +++ b/exporters/variables-scss/src/helpers/__tests__/cssObjectHelper.test.ts @@ -0,0 +1,53 @@ +import { convertToScss, deepMergeObjects } from '../cssObjectHelper'; + +const object1 = { + $grids: { + spacing: { + desktop: '$grid-spacing-desktop', + mobile: '$grid-spacing-mobile', + tablet: '$grid-spacing-tablet', + }, + }, +}; + +const object2 = { + $grids: { columns: '$grid-columns' }, +}; + +const mergedObject = { + $grids: { + spacing: { + desktop: '$grid-spacing-desktop', + mobile: '$grid-spacing-mobile', + tablet: '$grid-spacing-tablet', + }, + columns: '$grid-columns', + }, +}; + +const scssObject = `$grids: ( +spacing: ( +desktop: $grid-spacing-desktop, +mobile: $grid-spacing-mobile, +tablet: $grid-spacing-tablet, +), +columns: $grid-columns, +),`; + +describe('cssObjectHelper', () => { + describe('mergeObjects', () => { + it('should merge objects', () => { + const result = deepMergeObjects(object1, object2); + + expect(result).toStrictEqual(mergedObject); + }); + }); + + describe('convertToScss', () => { + it('should convert object to SCSS', () => { + const result = convertToScss(mergedObject); + + expect(result).toBe(scssObject); + }); + }); +}); diff --git a/exporters/variables-scss/src/helpers/__tests__/tokenHelper.test.ts b/exporters/variables-scss/src/helpers/__tests__/tokenHelper.test.ts index 9b7c0af375..b97c646f57 100644 --- a/exporters/variables-scss/src/helpers/__tests__/tokenHelper.test.ts +++ b/exporters/variables-scss/src/helpers/__tests__/tokenHelper.test.ts @@ -1,5 +1,5 @@ import { Token, TokenGroup } from '@supernovaio/sdk-exporters'; -import { formatTokenName, tokenVariableName } from '../tokenHelper'; +import { addEmptyLineBetweenTokenGroups, formatTokenName, sortTokens, tokenVariableName } from '../tokenHelper'; import { exampleMockedGroups, exampleMockedTokens } from '../../formatters/__fixtures__/mockedExampleTokens'; const dataProvider = [ @@ -47,4 +47,32 @@ describe('tokenHelper', () => { expect(result).toBe('$grid-columns: 12 !default;'); }); }); + + describe('sortTokens', () => { + it('should sort tokens by variable name', () => { + const tokens = Array.from(exampleMockedTokens.values()); + const tokenGroups = exampleMockedGroups; + const hasParentPrefix = true; + const group = 'Grid'; + const sortByNumValue = false; + + const result = sortTokens(tokens, tokenGroups, hasParentPrefix, group, sortByNumValue); + + expect(result[0]?.origin?.name).toBe('Grid/Columns'); + expect(result[1]?.origin?.name).toBe('Grid/spacing/desktop'); + }); + }); + + describe('addEmptyLineBetweenTokenGroups', () => { + it('should add empty line between token groups', () => { + const cssTokens = [ + { css: '$grid-columns: 12 !default;', parentGroupId: '1' }, + { css: '$grid-spacing-desktop: 32px !default;', parentGroupId: '2' }, + ]; + + const result = addEmptyLineBetweenTokenGroups(cssTokens); + + expect(result).toBe('$grid-columns: 12 !default;\n\n$grid-spacing-desktop: 32px !default;'); + }); + }); }); diff --git a/exporters/variables-scss/src/helpers/colorHelper.ts b/exporters/variables-scss/src/helpers/colorHelper.ts new file mode 100644 index 0000000000..3e07264e2b --- /dev/null +++ b/exporters/variables-scss/src/helpers/colorHelper.ts @@ -0,0 +1,44 @@ +const SHORT_HEX_WITHOUT_ALPHA_LENGTH = 3; +const SHORT_HEX_WITH_ALPHA_LENGTH = 4; +const LONG_HEX_WITH_ALPHA_LENGTH = 8; + +export const canHexBeShortened = (hex: string) => { + return hex.length % 2 === 0 && [...Array(hex.length / 2)].every((_, index) => hex[2 * index] === hex[2 * index + 1]); +}; + +export const shortenHex = (hex: string): string => { + return hex + .split('') + .map((char, index) => (index % 2 === 0 ? char : '')) + .join(''); +}; + +export const removeAlphaChannel = (hex: string): string => { + if (hex.length === LONG_HEX_WITH_ALPHA_LENGTH && hex.endsWith('ff')) { + return hex.slice(0, -2); + } + + if (hex.length === SHORT_HEX_WITH_ALPHA_LENGTH && hex.endsWith('f')) { + return hex.slice(0, -1); + } + + return hex; +}; + +export const normalizeColor = (hexCode: string): string => { + const isShortHex = [SHORT_HEX_WITHOUT_ALPHA_LENGTH, SHORT_HEX_WITH_ALPHA_LENGTH].includes(hexCode.length); + + let processedHex; + + if (isShortHex) { + processedHex = hexCode; + } else if (canHexBeShortened(hexCode)) { + processedHex = shortenHex(hexCode); + } else { + processedHex = hexCode; + } + + processedHex = removeAlphaChannel(processedHex); + + return `#${processedHex}`; +}; diff --git a/exporters/variables-scss/src/helpers/cssObjectHelper.ts b/exporters/variables-scss/src/helpers/cssObjectHelper.ts new file mode 100644 index 0000000000..b7255dec43 --- /dev/null +++ b/exporters/variables-scss/src/helpers/cssObjectHelper.ts @@ -0,0 +1,31 @@ +import { CssObjectType } from '../generators/cssObjectGenerator'; + +export const deepMergeObjects = (obj1: CssObjectType, obj2: CssObjectType): CssObjectType => { + return Object.entries(obj2).reduce( + (result, [key, value]) => { + if (typeof value === 'object' && value !== null && typeof result[key] === 'object') { + result[key] = deepMergeObjects(result[key] as CssObjectType, value as CssObjectType); + } else { + result[key] = value; + } + + return result; + }, + { ...obj1 }, + ); +}; + +export function convertToScss(obj: CssObjectType): string { + return Object.entries(obj) + .map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + const nestedScss = convertToScss(value as CssObjectType); + + return `${key}: (\n${nestedScss}\n),\n`; + } + + return `${key}: ${value},\n`; + }) + .join('') + .slice(0, -1); +} diff --git a/exporters/variables-scss/src/helpers/tokenHelper.ts b/exporters/variables-scss/src/helpers/tokenHelper.ts index 77b737aab9..9351a73b86 100644 --- a/exporters/variables-scss/src/helpers/tokenHelper.ts +++ b/exporters/variables-scss/src/helpers/tokenHelper.ts @@ -19,3 +19,48 @@ export const formatTokenName = (name: string, value: string | number, unit?: str return `$${name}: ${value} !default;`; }; + +export const sortTokens = ( + tokens: Token[], + tokenGroups: Array, + hasParentPrefix: boolean, + group: string, + sortByNumValue: boolean, +) => { + const sortedTokens = tokens.sort((a, b) => { + if (sortByNumValue) { + const aNumMatch = a.name.match(/\d+$/); + const bNumMatch = b.name.match(/\d+$/); + + if (aNumMatch && bNumMatch) { + return parseInt(aNumMatch[0], 10) - parseInt(bNumMatch[0], 10); + } + } + + const aCompare = tokenVariableName(a, tokenGroups, hasParentPrefix); + const bCompare = tokenVariableName(b, tokenGroups, hasParentPrefix); + + return aCompare.localeCompare(bCompare); + }); + + return sortedTokens; +}; + +export const addEmptyLineBetweenTokenGroups = (cssTokens: { css: string | null; parentGroupId: string }[]): string => { + let lastGroupId: string | null = null; + const cssWithGroupSpacing: string[] = []; + + cssTokens.forEach(({ css, parentGroupId }) => { + if (lastGroupId && parentGroupId !== lastGroupId && css) { + cssWithGroupSpacing.push(''); + } + + if (css) { + cssWithGroupSpacing.push(css); + } + + lastGroupId = parentGroupId; + }); + + return cssWithGroupSpacing.join('\n'); +}; diff --git a/exporters/variables-scss/src/index.ts b/exporters/variables-scss/src/index.ts index 5ddac1887c..ff61c8a66c 100644 --- a/exporters/variables-scss/src/index.ts +++ b/exporters/variables-scss/src/index.ts @@ -53,27 +53,35 @@ Pulsar.export(async (sdk: Supernova, context: PulsarContext): Promise { + let cache: string[] | null = []; + const str = JSON.stringify( + obj, + (key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache?.includes(value)) { + return 'CIRCULAR_REFERENCE'; + } + cache?.push(value); + } + + return value; + }, + 2, + ); + cache = null; + + return str; + }; + return [ ...files.map((file) => { return createTextFile('./global/', file.fileName, file.content); }), - // only for debugging purposes - createTextFile( - './original-data/', - '_original-tokens.json', - JSON.stringify( - tokens.map((token) => ({ - tokenType: token.tokenType, - // @ts-ignore-next-line - origin: token.origin.name, - name: token.name, - // @ts-ignore-next-line - value: token.value, - })), - null, - 2, - ), - ), + // TODO: Only for debugging purposes - remove for production! + createTextFile('./original-data/', '_original-tokens.json', safeStringify(tokens)), + // TODO: Only for debugging purposes - remove for production! createTextFile('./original-data/', '_original-groups.json', JSON.stringify(tokenGroups, null, 2)), ]; });