diff --git a/.commitlintrc.js b/.commitlintrc.js index 8932f24bdb..963732bc94 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -19,6 +19,7 @@ module.exports = { 'web-react', 'web-twig', // Use when committing changes/additions/removals to exact exporter + 'exporter-js', 'exporter-scss', 'exporter-svg', // Use when affecting CI process diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fae8aee75f..4190315c1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,8 @@ The `footer` is optional. The [Commit Message Footer](#commit-footer) format des │ │ │ │ │ └─⫸ Summary in present tense. Sentence case. No period at the end. │ │ - │ └─⫸ Commit Scope: analytics|design-tokens|form-validations|icons|web|web-react|web-twig|exporter-scss| - │ exporter-svg|ci|repo + │ └─⫸ Commit Scope: analytics|design-tokens|form-validations|icons|web|web-react|web-twig|exporter-js| + | exporter-scss|exporter-svg|ci|repo │ └─⫸ Commit Type: Feat|Fix|Perf|Revert|Docs|Style|Refactor|Test|Chore|Deps ``` @@ -84,6 +84,7 @@ The following is the list of supported scopes: - Apps: - `demo` - Exporters: + - `exporter-js` - `exporter-scss` - `exporter-svg` - Packages: diff --git a/exporters/js/.eslintignore b/exporters/js/.eslintignore new file mode 100644 index 0000000000..01a6927ac6 --- /dev/null +++ b/exporters/js/.eslintignore @@ -0,0 +1,2 @@ +# Generated files used by Supernova +generated diff --git a/exporters/js/.eslintrc.js b/exporters/js/.eslintrc.js new file mode 100644 index 0000000000..eda1c89421 --- /dev/null +++ b/exporters/js/.eslintrc.js @@ -0,0 +1,46 @@ +module.exports = { + extends: [ + '../../.eslintrc', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + '@lmc-eu/eslint-config-jest', + ], + + parser: '@typescript-eslint/parser', // the TypeScript parser we installed earlier + + parserOptions: { + ecmaVersion: 'latest', + project: './tsconfig.eslint.json', + }, + + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.ts'], + }, + }, + }, + + plugins: ['promise', '@typescript-eslint', 'prettier'], + rules: { + // disable for `scripts` and `config` + '@typescript-eslint/no-var-requires': 'off', + // allow ++ in for loops + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], + // disabled due to typescript + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error', { allow: ['resolve', 'reject', 'done', 'next', 'error'] }], + // disabled due to typescript + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'warn', + // We are using typescript, disable jsdoc rules + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-param-type': 'off', + // allow reassign in properties + 'no-param-reassign': ['warn', { props: false }], + // support monorepos + 'import/no-extraneous-dependencies': ['error', { packageDir: ['./', '../../'] }], + }, +}; diff --git a/exporters/js/.gitignore b/exporters/js/.gitignore new file mode 100644 index 0000000000..b484745fd9 --- /dev/null +++ b/exporters/js/.gitignore @@ -0,0 +1,2 @@ +.build +.coverage diff --git a/exporters/js/CHANGELOG.md b/exporters/js/CHANGELOG.md new file mode 100644 index 0000000000..e4d87c4d45 --- /dev/null +++ b/exporters/js/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/exporters/js/CONTRIBUTING.md b/exporters/js/CONTRIBUTING.md new file mode 100644 index 0000000000..eeed7f2b9b --- /dev/null +++ b/exporters/js/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing + +## Development + +Please, read the Supernova Documentation below to start developing this package. +You will need to install an extension to your IDE to start with development. + +Distribution file `generated/functions.js` is assembled by Vite and `build` script. Please, do not edit this file manually. +All files in `src` directory is editable and buildable by `build` script. + +❗ Please, run `build` script for every change you make and commit generated file with other changes. +Supernova Cloud loads exporters directly from GitHub repository. + +## Supernova Documentation + +- [Supernova - Function List](https://developers.supernova.io/latest/design-system-model/function-list.html#search-fb31ced2-ca07-11ec-885b-510a619c4a1b) +- [Supernova - How to build Exporters](https://developers.supernova.io/latest/building-exporters/overview-1.html) +- [Supernova - How to build Exporters using JavaScript](https://developers.supernova.io/latest/building-exporters/building-exporters-101/using-javascript.html) diff --git a/exporters/js/LICENSE.md b/exporters/js/LICENSE.md new file mode 100644 index 0000000000..a1eff20bb7 --- /dev/null +++ b/exporters/js/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LMC s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/exporters/js/README.md b/exporters/js/README.md new file mode 100644 index 0000000000..281e41800a --- /dev/null +++ b/exporters/js/README.md @@ -0,0 +1,44 @@ +# Exporter Spirit JS + +[Supernova][supernova-studio] JS exporter made for Spirit Design System developed by [LMC][lmc]. + +## Token operations + +This exported does several operations with tokens: + +- All token groups except [Typography](#typography) are processed using a simple generate function. +- The first step is sorting. Measures are sorted by number in the token name, Generic Tokens (Other) by value, and the rest by its name. +- Next, each token is grouped and its value is prepared to print. Grouping is made using actual groups in Supernova and if these are not present, a common name prefix is used. Separate token values are printed as separate variables. +- Groups are used for printing objects with references to separate tokens and pluralized names. +- Shadows are grouped if same name. +- If Gradient names start with `gradients/gradient`, they are not used from Figma, but from Supernova + +### Typography + +As typography in Figma and Supernova are stored in named text style groups, these groups are used to generate objects with all the values from Supernova. They are grouped by breakpoints. +⚠️ We do not generate `link` typography tokens (styles that include `-link` in their name). + +#### Ebony Font Weight Exception + +Font Family Ebony has a different font weight mapping in Figma and in Adobe Fonts. To match these we set its own font weight numeric-name conversion. + +### Sorting + +Tokens are sorted alphabetically by origin (Figma) name or by name (Supernova). Except Measures - sorted by name number and Other - sorted by value. + +## Outputs: + +- borders.ts +- colors.ts +- gradients.ts +- measures.ts +- other.ts +- radii.ts +- shadows.ts +- typography.ts +- index.ts + +The index file contains exports from all other outputs. + +[supernova-studio]: https://github.com/Supernova-Studio +[lmc]: https://github.com/lmc-eu diff --git a/exporters/js/exporter.json b/exporters/js/exporter.json new file mode 100644 index 0000000000..2343e33d6b --- /dev/null +++ b/exporters/js/exporter.json @@ -0,0 +1,48 @@ +{ + "id": "eu.lmc.exporter-spirit-js", + "name": "Spirit JS Exporter", + "description": "Spirit JS Exporter", + "author": "Jan Kryšpín ", + "organization": "LMC s.r.o.", + "source_dir": "src", + "version": "1.0.0", + "usesBrands": true, + "config": { + "sources": "sources.json", + "output": "output.json", + "js": "generated/functions.js" + }, + "engines": { + "pulsar": "1.0.0", + "supernova": "1.0.0" + }, + "tags": ["JS", "Tokens", "Styles", "Spirit", "TS"], + "contributes": { + "configuration": [ + { + "key": "defaultFontSize", + "default": 16, + "type": "number", + "label": "Default project font-size in px", + "description": "Used for calculation to rem", + "category": "Advanced" + }, + { + "key": "fontFamilyFallback", + "default": ", sans-serif", + "type": "string", + "label": "Font Family fallback", + "description": "Font Family fallback", + "category": "Advanced" + }, + { + "key": "breakpoints", + "default": "mobile,tablet,desktop", + "type": "string", + "label": "Breakpoints", + "description": "List available breakpoints. Separate them with comma. Example and default value: 'mobile,tablet,desktop'.", + "category": "Advanced" + } + ] + } +} diff --git a/exporters/js/generated/functions.js b/exporters/js/generated/functions.js new file mode 100644 index 0000000000..d0e1f55955 --- /dev/null +++ b/exporters/js/generated/functions.js @@ -0,0 +1,33 @@ +"use strict";function T(t){return t.replace(/\s/g,"-").replace(/\//g,"-").replace(/-\d\d-/g,"-").replace(/--+/g,"-").toLowerCase()}function E(t){return t.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1).toLowerCase()).join("")}function C(t){return t.split("-").map((n,r)=>r>0?n.charAt(0).toUpperCase()+n.slice(1).toLowerCase():n).join("")}function O(t){return t.slice(-1)==="s"?t.replace(/.$/,""):t}function H(t,n){let r="";const l=[];return Object.entries(t).forEach(([$,o])=>{const g=C($);l.push(g),o.length>0&&(r=`${r} +export const ${g} = { +${o.map(m=>` ${C(m)}: ${C($)}${E(m)},`).join(` +`)} +}; +`)}),l.length>0&&(r=`${r} +export const ${n} = { + ${l.join(`, + `)}, +}; +`),r}function d(t,n){let r=t.toString();return+t!=0&&(n==="Pixels"&&(r+="px"),n==="rem"&&(r+="rem")),r}function L(t){const n=t.match(/.{1,2}/g);let r=!0;return n&&n.forEach(l=>{r&&(r=/^(.)\1+$/.test(l))}),r?`${t.substring(0,1)}${t.substring(2,3)}${t.substring(4,5)}`:t}function W(t){return t.a<255?`#${t.hex}`:`#${L(t.hex.substring(0,6))}`}function M(t,n){const r=n.y-t.y,l=n.x-t.x;let o=Math.atan2(r,l)*180/Math.PI;return o+=90,(o<0?360+o:o)%360}function b(t,n){const r=T(t.origin?t.origin.name:t.name),l=T(n.origin?n.origin.name:n.name);return r.localeCompare(l)}function D(t,n){return+t.value.text-+n.value.text}function z(t,n,r,l){const $=r.trim().split(",");let o="",g="";if($.some(c=>((t.origin?t.origin.name:t.name).includes(c)&&(o=c),(n.origin?n.origin.name:n.name).includes(c)&&(g=c),!1)),l&&!o)return-1;let m=$.indexOf(o)-$.indexOf(g);return m===0&&(m=b(t,n)),m}function F(t){return t.name.match(/ \d$/)}function G(t,n,r=[],l=!1,$=!1,o="",g=""){let m=n.sort((e,a)=>{if(l){const p=e.name.match(/\d+$/),i=a.name.match(/\d+$/);if(p&&i)return parseInt(p[0],10)-parseInt(i[0],10)}return $?D(e,a):b(e,a)});o.length>0&&(m=n.sort((e,a)=>z(e,a,o,$)));const c=[],v={};m.forEach(e=>{if(g.length>0&&!e.name.startsWith(g))return;let a=T(e.name);e.origin&&!e.origin.name.toLowerCase().startsWith("gradients/gradient")&&(a=T(e.origin.name));const p=F(e);let i="";!p&&r.length>0&&r.forEach(f=>{Object.values(f.tokenIds).indexOf(e.id)>-1&&f.isRoot===!1&&(i=O(T(f.name)))});const x=a.split("-"),h=i===""?x[0]:i,S=i===""?a.replace(`${x[0]}-`,""):a.replace(`${i}-`,"");v[h]&&v[h].length>0?!p&&x[0]!==a?v[h].push(S):v[h]=[]:!p&&x[0]!==a?v[h]=[S]:v[h]=[];let u="";if(e.tokenType==="Color")u=W(e.value);else if(e.tokenType==="Radius")u=d(e.value.radius.measure,e.value.radius.unit);else if(e.tokenType==="GenericToken")e.propertyValues.unitless?u=e.value.text:u=d(e.value.text,"Pixels");else if(e.tokenType==="Shadow")u=`${d(e.value.x.measure,e.value.x.unit)} ${d(e.value.y.measure,e.value.y.unit)} ${d(e.value.radius.measure,e.value.radius.unit)} ${d(e.value.spread.measure,e.value.spread.unit)} ${W(e.value.color)}`;else if(e.tokenType==="Gradient"){let f="linear-gradient",P=`${Math.round(M(e.value.from,e.value.to)*100)/100}deg`;e.value.type==="Radial"&&(f="radial-gradient",P="circle at center"),u=`${f}(var(--angle, ${P}), ${e.value.stops.map(y=>`${W(y.color)} ${Math.round(y.position*10)/10*100}%`).join(", ")})`}else e.tokenType==="Border"?u=e.propertyValues.style??d(e.value.width.measure,e.value.width.unit):u=d(e.value.measure,e.value.unit);if(p){const f=T(e.name.replace(/ \d$/,"")),P=c.filter(j=>j.startsWith(`$${f}: `))[0],y=c.indexOf(P);y>-1&&(c[y]=c[y].replace(/= '(.*)';/g,`= '${u}, $1';`))}else c.push(`export const ${C(a)} = ${u==="0"?0:`'${u}'`};`)});const s=m.length===0?"":H(v,t);return`${c.join(` +`)} +${s}`}const V={400:300,600:400};function A(t,n,r,l=""){const $=t.sort(b),o=l.trim().split(","),g={};$.forEach(s=>{var w;const e=T(((w=s.origin)==null?void 0:w.name)||s.name);let a=e,p=o[0];o.forEach(I=>{a.includes(I)&&(p=I,a=a.replace(`-${I}`,""))});const i=d(Math.round(s.value.fontSize.measure/n*1e3)/1e3,"rem");let x="normal",h=+s.value.font.subfamily;s.value.font.family==="Ebony"&&(h=V[h]),e.includes("italic")&&(x="italic");const S=s.value.lineHeight&&Math.round(s.value.lineHeight.measure/100*1e3)/1e3,u=d(s.value.letterSpacing.measure,s.value.letterSpacing.unit),f=s.value.textDecoration.toLowerCase(),P=d(s.value.paragraphIndent.measure,s.value.paragraphIndent.unit),y=s.value.textCase==="Original"?"none":s.value.textCase.toLowerCase(),j={fontFamily:`'${s.value.font.family}'${r}`,fontSize:i,fontStyle:x,fontWeight:h,lineHeight:S,letterSpacing:u,textDecoration:f,paragraphIndent:P,textTransform:y};typeof g[a]<"u"?g[a][p]=j:g[a]={[p]:j}});const m=[],c=[];Object.entries(g).forEach(([s,e])=>{if(s.includes("-link"))return;c.push(`${C(s)}: ${C(s)},`);const a=[];o.forEach(p=>{const i=e[p];if(typeof i<"u"){const x=i.lineHeight?` + lineHeight: ${i.lineHeight},`:"",h=i.letterSpacing!=="0"?` + letterSpacing: ${i.letterSpacing},`:"",S=i.textDecoration!=="none"?` + textDecoration: ${i.textDecoration},`:"",u=i.paragraphIndent!=="0"?` + textIndent: ${i.paragraphIndent},`:"",f=i.textTransform!=="none"?` + textTransform: ${i.textTransform},`:"";a.push(`${p}: { + fontFamily: "${i.fontFamily}", + fontSize: '${i.fontSize}', + fontStyle: '${i.fontStyle}', + fontWeight: ${i.fontWeight},${x}${h}${S}${u}${f} + },`)}}),m.push(`export const ${C(s)} = { + ${a.join(` + `)} +}; +`)});const v=`export const styles = { + ${c.join(` + `)} +};`;return`${m.join(` +`)} +${v} +`}Pulsar.registerFunction("generateSimple",G);Pulsar.registerFunction("generateTypography",A); diff --git a/exporters/js/jest.config.js b/exporters/js/jest.config.js new file mode 100644 index 0000000000..dc025533ea --- /dev/null +++ b/exporters/js/jest.config.js @@ -0,0 +1,49 @@ +const config = { + // The root directory that Jest should scan for tests and modules within. + // https://jestjs.io/docs/configuration#rootdir-string + rootDir: './', + + // This option tells Jest that all imported modules in your tests should be mocked automatically. + // https://jestjs.io/docs/configuration#automock-boolean + automock: false, + + // Indicates whether each individual test should be reported during the run. + // https://jestjs.io/docs/configuration#verbose-boolean + verbose: false, + + // A map from regular expressions to paths to transformers + // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object + transform: { + '^.+\\.(t|j)s?$': ['/../../node_modules/@swc/jest'], + }, + + // The test environment that will be used for testing. + // https://jestjs.io/docs/configuration#testenvironment-string + // testEnvironment: 'jsdom', + + // An array of regexp pattern strings that are matched against all test paths before executing the test + // https://jestjs.io/docs/configuration#testpathignorepatterns-arraystring + testPathIgnorePatterns: ['/dist/', '/node_modules/', '.*__tests__/.*DataProvider.ts'], + + // The directory where Jest should output its coverage files. + // https://jestjs.io/docs/configuration#coveragedirectory-string + coverageDirectory: './.coverage', + + // An array of glob patterns indicating a set of files for which coverage information should be collected. + // https://jestjs.io/docs/configuration#collectcoveragefrom-array + collectCoverageFrom: ['/src/**/*.{js,ts}', '!/src/**/*.d.ts'], + + // An array of regexp pattern strings that are matched against all file paths before executing the test. + // https://jestjs.io/docs/configuration#coveragepathignorepatterns-arraystring + coveragePathIgnorePatterns: ['__fixtures__'], + + // A list of reporter names that Jest uses when writing coverage reports. Any istanbul reporter can be used. + // https://jestjs.io/docs/configuration#coveragereporters-arraystring--string-options + coverageReporters: ['text', 'text-summary', ['lcov', { projectRoot: '../../' }]], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test. + // https://jestjs.io/docs/configuration#setupfilesafterenv-array + // setupFilesAfterEnv: ['@testing-library/jest-dom'], +}; + +module.exports = config; diff --git a/exporters/js/output.json b/exporters/js/output.json new file mode 100644 index 0000000000..863b90c253 --- /dev/null +++ b/exporters/js/output.json @@ -0,0 +1,40 @@ +{ + "blueprints": [ + { + "invoke": "borders.pr", + "write_to": "borders.ts" + }, + { + "invoke": "colors.pr", + "write_to": "colors.ts" + }, + { + "invoke": "gradients.pr", + "write_to": "gradients.ts" + }, + { + "invoke": "measures.pr", + "write_to": "measures.ts" + }, + { + "invoke": "other.pr", + "write_to": "other.ts" + }, + { + "invoke": "radii.pr", + "write_to": "radii.ts" + }, + { + "invoke": "shadows.pr", + "write_to": "shadows.ts" + }, + { + "invoke": "typography.pr", + "write_to": "typography.ts" + }, + { + "invoke": "index.pr", + "write_to": "index.ts" + } + ] +} diff --git a/exporters/js/package.json b/exporters/js/package.json new file mode 100644 index 0000000000..bde1d82525 --- /dev/null +++ b/exporters/js/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lmc-eu/spirit-exporters-js", + "version": "1.0.0", + "description": "Spirit JS Exporter for Supernova", + "license": "MIT", + "private": true, + "scripts": { + "build": "vite build", + "lint": "eslint ./", + "lint:fix": "yarn lint --fix", + "test": "npm-run-all lint test:unit:coverage types", + "test:unit": "jest", + "test:unit:watch": "yarn test:unit --watchAll", + "test:unit:coverage": "yarn test:unit --coverage", + "types": "tsc" + }, + "devDependencies": { + "@swc/core": "1.3.94", + "@swc/jest": "0.2.29", + "jest": "29.7.0", + "typescript": "4.9.5", + "vite": "4.5.0" + } +} diff --git a/exporters/js/sources.json b/exporters/js/sources.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/exporters/js/sources.json @@ -0,0 +1 @@ +{} diff --git a/exporters/js/src/borders.pr b/exporters/js/src/borders.pr new file mode 100644 index 0000000000..11e3b29cd1 --- /dev/null +++ b/exporters/js/src/borders.pr @@ -0,0 +1,4 @@ +// Generated Borders from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Border", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("borders", ds.tokensByType("Border", ds.currentBrand().id), ds.tokenGroupsOfType("Border", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/colors.pr b/exporters/js/src/colors.pr new file mode 100644 index 0000000000..ef63522fb2 --- /dev/null +++ b/exporters/js/src/colors.pr @@ -0,0 +1,4 @@ +// Generated Colors from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Color", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("colors", ds.tokensByType("Color", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/gradients.pr b/exporters/js/src/gradients.pr new file mode 100644 index 0000000000..2f6905bc35 --- /dev/null +++ b/exporters/js/src/gradients.pr @@ -0,0 +1,4 @@ +// Generated Gradients from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Gradient", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("gradients", ds.tokensByType("Gradient", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/index.pr b/exporters/js/src/index.pr new file mode 100644 index 0000000000..c3bd7c48cc --- /dev/null +++ b/exporters/js/src/index.pr @@ -0,0 +1,8 @@ +export * from './borders'; +export * from './colors'; +export * from './gradients'; +export * from './measures'; +export * from './other'; +export * from './radii'; +export * from './shadows'; +export * from './typography'; diff --git a/exporters/js/src/js/formatters/__tests__/color.test.ts b/exporters/js/src/js/formatters/__tests__/color.test.ts new file mode 100644 index 0000000000..df541b755d --- /dev/null +++ b/exporters/js/src/js/formatters/__tests__/color.test.ts @@ -0,0 +1,10 @@ +import { formatColor } from '../color'; + +describe('formatColor', () => { + it.each([ + // name, expected + [{ a: 125, hex: '123456' }, '#123456'], + ])('should format color', (color, expected) => { + expect(formatColor(color)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/formatters/color.ts b/exporters/js/src/js/formatters/color.ts new file mode 100644 index 0000000000..81164de7b6 --- /dev/null +++ b/exporters/js/src/js/formatters/color.ts @@ -0,0 +1,14 @@ +import { normalizeColor } from '../normalizers/color'; + +type ColorShape = { + a: number; + hex: string; +}; + +export function formatColor(color: ColorShape): string { + if (color.a < 255) { + return `#${color.hex}`; + } + + return `#${normalizeColor(color.hex.substring(0, 6))}`; +} diff --git a/exporters/js/src/js/generators/__fixtures__/simpleTokens.json b/exporters/js/src/js/generators/__fixtures__/simpleTokens.json new file mode 100644 index 0000000000..d68cdc7178 --- /dev/null +++ b/exporters/js/src/js/generators/__fixtures__/simpleTokens.json @@ -0,0 +1,40 @@ +[ + { + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "10", + "description": "10", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Base/Pink/10" + }, + "value": { + "hex": "fef0f5ff", + "r": 254, + "g": 240, + "b": 245, + "a": 255, + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "20", + "description": "20", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Base/Pink/20" + }, + "value": { + "hex": "fdd5e4ff", + "r": 253, + "g": 213, + "b": 228, + "a": 255, + "referencedToken": null + } + } +] diff --git a/exporters/js/src/js/generators/__fixtures__/typographyTokens.json b/exporters/js/src/js/generators/__fixtures__/typographyTokens.json new file mode 100644 index 0000000000..fa21419982 --- /dev/null +++ b/exporters/js/src/js/generators/__fixtures__/typographyTokens.json @@ -0,0 +1,98 @@ +[ + { + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "Text-Light", + "description": "Text-Light", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Body/Medium/Text-Light" + }, + "value": { + "font": { + "family": "Test", + "subfamily": "300" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "Text-Regular", + "description": "Text-Regular", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Body/Medium/Text-Regular" + }, + "value": { + "font": { + "family": "Test", + "subfamily": "400" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "Text-Italic", + "description": "Text-Italic", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Body/Medium/Text-Italic" + }, + "value": { + "font": { + "family": "Ebony", + "subfamily": "400" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + } +] diff --git a/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap b/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap new file mode 100644 index 0000000000..e8225882bd --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateSimple should generate simple output 1`] = ` +"export const basePink10 = '#fef0f5'; +export const basePink20 = '#fdd5e4'; + +export const base = { + pink10: basePink10, + pink20: basePink20, +}; + +export const test = { + base, +}; +" +`; diff --git a/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap b/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap new file mode 100644 index 0000000000..d1717247fd --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateTypography should generate simple output 1`] = ` +"export const bodyMediumTextItalic = { + mobile: { + fontFamily: "'Ebony', sans-serif", + fontSize: '1rem', + fontStyle: 'italic', + fontWeight: 300, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const bodyMediumTextLight = { + mobile: { + fontFamily: "'Test', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 300, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const bodyMediumTextRegular = { + mobile: { + fontFamily: "'Test', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const styles = { + bodyMediumTextItalic: bodyMediumTextItalic, + bodyMediumTextLight: bodyMediumTextLight, + bodyMediumTextRegular: bodyMediumTextRegular, +}; +" +`; diff --git a/exporters/js/src/js/generators/__tests__/simple.test.ts b/exporters/js/src/js/generators/__tests__/simple.test.ts new file mode 100644 index 0000000000..f2a5f74240 --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/simple.test.ts @@ -0,0 +1,9 @@ +import { Token } from '../../index'; +import { generateSimple } from '../simple'; +import simpleTokens from '../__fixtures__/simpleTokens.json'; + +describe('generateSimple', () => { + it.each([[simpleTokens]])('should generate simple output', (allTokens: Array) => { + expect(generateSimple('test', allTokens)).toMatchSnapshot(); + }); +}); diff --git a/exporters/js/src/js/generators/__tests__/typography.test.ts b/exporters/js/src/js/generators/__tests__/typography.test.ts new file mode 100644 index 0000000000..625ed6c221 --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/typography.test.ts @@ -0,0 +1,8 @@ +import { generateTypography } from '../typography'; +import typographyTokens from '../__fixtures__/typographyTokens.json'; + +describe('generateTypography', () => { + it.each([[typographyTokens]])('should generate simple output', (allTokens) => { + expect(generateTypography(allTokens, '10', ', sans-serif', 'mobile,tablet,desktop')).toMatchSnapshot(); + }); +}); diff --git a/exporters/js/src/js/generators/simple.ts b/exporters/js/src/js/generators/simple.ts new file mode 100644 index 0000000000..a40be6af04 --- /dev/null +++ b/exporters/js/src/js/generators/simple.ts @@ -0,0 +1,148 @@ +// Do not want to deal with exact shape of Token +// @TODO: https://github.com/lmc-eu/spirit-design-system/issues/470 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { kebabCaseToCamelCase, slugifyName } from '../normalizers/names'; +import { singular } from '../normalizers/singular'; +import { printTypes } from '../printers/types'; +import { printUnit } from '../printers/unit'; +import { formatColor } from '../formatters/color'; +import { normalizeGradientAngle } from '../normalizers/gradients'; +import { localeSort } from '../sorters/localeSort'; +import { valueSort } from '../sorters/valueSort'; +import { breakpointSort } from '../sorters/breakpointSort'; +import { Token } from '..'; + +function isGroupToken(token: Token): boolean { + // Check if token is a group token, because it has a single digit at the end of the name + return token.name.match(/ \d$/); +} + +export function generateSimple( + globalName, + allTokens: Array, + groups = [], + sortByNum = false, + sortByValue = false, + breakpointsString = '', + skipByName = '', +): string { + let tokens = allTokens.sort((a, b) => { + if (sortByNum) { + const aNumMatch = a.name.match(/\d+$/); + const bNumMatch = b.name.match(/\d+$/); + + if (aNumMatch && bNumMatch) { + return parseInt(aNumMatch[0], 10) - parseInt(bNumMatch[0], 10); + } + } + + if (sortByValue) { + return valueSort(a, b); + } + + return localeSort(a, b); + }); + + if (breakpointsString.length > 0) { + tokens = allTokens.sort((a, b) => { + return breakpointSort(a, b, breakpointsString, sortByValue); + }); + } + + const vars: string[] = []; + const types = {}; + tokens.forEach((token) => { + if (skipByName.length > 0 && !token.name.startsWith(skipByName)) { + return; + } + // Get correct name of token + let name = slugifyName(token.name); + + // The Gradients exception is temporary, until JDS fix their naming + if (token.origin && !token.origin.name.toLowerCase().startsWith('gradients/gradient')) { + name = slugifyName(token.origin.name); + } + + const groupToken = isGroupToken(token); + + // Set token types + let groupName = ''; + if (!groupToken && groups.length > 0) { + groups.forEach((group: Token) => { + if (Object.values(group.tokenIds).indexOf(token.id) > -1 && group.isRoot === false) { + groupName = singular(slugifyName(group.name)); + } + }); + } + + const split = name.split('-'); + const typeName = groupName === '' ? split[0] : groupName; + const tokenNameWithoutType = + groupName === '' ? name.replace(`${split[0]}-`, '') : name.replace(`${groupName}-`, ''); + if (types[typeName] && types[typeName].length > 0) { + if (!groupToken && split[0] !== name) { + types[typeName].push(tokenNameWithoutType); + } else { + types[typeName] = []; + } + } else if (!groupToken && split[0] !== name) { + types[typeName] = [tokenNameWithoutType]; + } else { + types[typeName] = []; + } + + // Set value + let value = ''; + if (token.tokenType === 'Color') { + value = formatColor(token.value); + } else if (token.tokenType === 'Radius') { + value = printUnit(token.value.radius.measure, token.value.radius.unit); + } else if (token.tokenType === 'GenericToken') { + const unitlessProp = token.propertyValues.unitless; + if (unitlessProp) { + value = token.value.text; + } else { + value = printUnit(token.value.text, 'Pixels'); + } + } else if (token.tokenType === 'Shadow') { + value = `${printUnit(token.value.x.measure, token.value.x.unit)} ${printUnit( + token.value.y.measure, + token.value.y.unit, + )} ${printUnit(token.value.radius.measure, token.value.radius.unit)} ${printUnit( + token.value.spread.measure, + token.value.spread.unit, + )} ${formatColor(token.value.color)}`; + } else if (token.tokenType === 'Gradient') { + let gradientType = 'linear-gradient'; + let gradientDirection = `${Math.round(normalizeGradientAngle(token.value.from, token.value.to) * 100) / 100}deg`; + if (token.value.type === 'Radial') { + gradientType = 'radial-gradient'; + gradientDirection = 'circle at center'; + } + value = `${gradientType}(var(--angle, ${gradientDirection}), ${token.value.stops + .map((stop) => `${formatColor(stop.color)} ${(Math.round(stop.position * 10) / 10) * 100}%`) + .join(', ')})`; + } else if (token.tokenType === 'Border') { + const styleProp = token.propertyValues.style; + value = styleProp ?? printUnit(token.value.width.measure, token.value.width.unit); + } else { + value = printUnit(token.value.measure, token.value.unit); + } + + if (groupToken) { + const nameWithoutGroup = slugifyName(token.name.replace(/ \d$/, '')); + const groupOriginal = vars.filter((item) => item.startsWith(`$${nameWithoutGroup}: `))[0]; + const index = vars.indexOf(groupOriginal); + if (index > -1) { + vars[index] = vars[index].replace(/= '(.*)';/g, `= '${value}, $1';`); + } + } else { + vars.push(`export const ${kebabCaseToCamelCase(name)} = ${value === '0' ? 0 : `'${value}'`};`); + } + }); + + const typesPrint = tokens.length === 0 ? '' : printTypes(types, globalName); + + return `${vars.join('\n')}\n${typesPrint}`; +} diff --git a/exporters/js/src/js/generators/typography.ts b/exporters/js/src/js/generators/typography.ts new file mode 100644 index 0000000000..2aa20eeee2 --- /dev/null +++ b/exporters/js/src/js/generators/typography.ts @@ -0,0 +1,113 @@ +// Do not want to deal with exact shape of Token +// @TODO: https://github.com/lmc-eu/spirit-design-system/issues/470 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { kebabCaseToCamelCase, slugifyName } from '../normalizers/names'; +import { printUnit } from '../printers/unit'; +import { localeSort } from '../sorters/localeSort'; +import { Token } from '..'; + +const ebonyFontWeights = { + 400: 300, + 600: 400, +}; + +export function generateTypography( + allTokens: Array, + defaultFontSize: string, + fontFamilyFallback: string, + breakpointsString = '', +) { + const tokens = allTokens.sort(localeSort); + + const breakpoints = breakpointsString.trim().split(','); + const styles = {}; + tokens.forEach((token) => { + const name = slugifyName(token.origin?.name || token.name); + let nameWithoutBreakpoint = name; + let breakpoint = breakpoints[0]; + breakpoints.forEach((bp) => { + if (nameWithoutBreakpoint.includes(bp)) { + breakpoint = bp; + nameWithoutBreakpoint = nameWithoutBreakpoint.replace(`-${bp}`, ''); + } + }); + + const fontSize = printUnit(Math.round((token.value.fontSize.measure / defaultFontSize) * 1000) / 1000, 'rem'); + let fontStyle = 'normal'; + let fontWeight = +token.value.font.subfamily; + + // Font Ebony has a different font weight mapping, so we remap these values directly + if (token.value.font.family === 'Ebony') { + fontWeight = ebonyFontWeights[fontWeight]; + } + + // TODO: This is a hack to get around the fact that I don't know how to check if font is italic in JS + if (name.includes('italic')) { + fontStyle = 'italic'; + } + + const lineHeight = token.value.lineHeight && Math.round((token.value.lineHeight.measure / 100) * 1000) / 1000; + const letterSpacing = printUnit(token.value.letterSpacing.measure, token.value.letterSpacing.unit); + const textDecoration = token.value.textDecoration.toLowerCase(); + const paragraphIndent = printUnit(token.value.paragraphIndent.measure, token.value.paragraphIndent.unit); + const textTransform = token.value.textCase === 'Original' ? 'none' : token.value.textCase.toLowerCase(); + const tokenVals = { + fontFamily: `'${token.value.font.family}'${fontFamilyFallback}`, + fontSize, + fontStyle, + fontWeight, + lineHeight, + letterSpacing, + textDecoration, + paragraphIndent, + textTransform, + }; + + if (typeof styles[nameWithoutBreakpoint] !== 'undefined') { + styles[nameWithoutBreakpoint][breakpoint] = tokenVals; + } else { + styles[nameWithoutBreakpoint] = { + [breakpoint]: tokenVals, + }; + } + }); + + const vars = []; + const list = []; + Object.entries(styles).forEach(([styleName, styleBreakpoints]) => { + if (styleName.includes('-link')) { + return; + } + list.push(`${kebabCaseToCamelCase(styleName)}: ${kebabCaseToCamelCase(styleName)},`); + const breakpointValues = []; + breakpoints.forEach((breakpoint) => { + const breakpointVal = styleBreakpoints[breakpoint]; + if (typeof breakpointVal !== 'undefined') { + const printLineHeight = breakpointVal.lineHeight ? `\n lineHeight: ${breakpointVal.lineHeight},` : ''; + const printLetterSpacing = + breakpointVal.letterSpacing !== '0' ? `\n letterSpacing: ${breakpointVal.letterSpacing},` : ''; + const printTextDecoration = + breakpointVal.textDecoration !== 'none' ? `\n textDecoration: ${breakpointVal.textDecoration},` : ''; + const printParagraphIndent = + breakpointVal.paragraphIndent !== '0' ? `\n textIndent: ${breakpointVal.paragraphIndent},` : ''; + const printTextTransform = + breakpointVal.textTransform !== 'none' ? `\n textTransform: ${breakpointVal.textTransform},` : ''; + breakpointValues.push(`${breakpoint}: { + fontFamily: "${breakpointVal.fontFamily}", + fontSize: '${breakpointVal.fontSize}', + fontStyle: '${breakpointVal.fontStyle}', + fontWeight: ${breakpointVal.fontWeight},${printLineHeight}${printLetterSpacing}${printTextDecoration}${printParagraphIndent}${printTextTransform} + },`); + } + }); + vars.push(`export const ${kebabCaseToCamelCase(styleName)} = { + ${breakpointValues.join('\n ')} +};\n`); + }); + const listPrint = `export const styles = { + ${list.join('\n ')} +};`; + + return `${vars.join('\n')}\n${listPrint}\n`; +} diff --git a/exporters/js/src/js/index.ts b/exporters/js/src/js/index.ts new file mode 100644 index 0000000000..c56915dfb3 --- /dev/null +++ b/exporters/js/src/js/index.ts @@ -0,0 +1,30 @@ +import { generateSimple } from './generators/simple'; +import { generateTypography } from './generators/typography'; + +export interface Origin { + source: string; + id: string; + name: string; +} + +export interface Token { + id: string; + name: string; + description: string; + tokenType: string; + origin?: Origin; + value: Record; + isRoot?: boolean; + path?: Array; + tokenIds?: Array; + subgroups?: Record; +} + +// Pulsar is global-scope object of Supernova, accesible only inside the platform +// @see: https://developers.supernova.io/building-exporters/creating-new-exporter/using-javascript +// eslint-disable-next-line no-undef +// @ts-expect-error TS2304: Cannot find name 'Pulsar'. +Pulsar.registerFunction('generateSimple', generateSimple); +// eslint-disable-next-line no-undef +// @ts-expect-error TS2304: Cannot find name 'Pulsar'. +Pulsar.registerFunction('generateTypography', generateTypography); diff --git a/exporters/js/src/js/normalizers/__tests__/color.test.js b/exporters/js/src/js/normalizers/__tests__/color.test.js new file mode 100644 index 0000000000..11f5a9ea22 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/color.test.js @@ -0,0 +1,10 @@ +import { normalizeColor } from '../color'; + +describe('normalizeColor', () => { + it.each([ + // name, expected + ['123456', '123456'], + ])('should normalize color', (color, expected) => { + expect(normalizeColor(color)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/gradients.test.js b/exporters/js/src/js/normalizers/__tests__/gradients.test.js new file mode 100644 index 0000000000..95c1922f45 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/gradients.test.js @@ -0,0 +1,10 @@ +import { normalizeGradientAngle } from '../gradients'; + +describe('normalizeGradientAngle', () => { + it.each([ + // from, to, expected + [{ x: 0, y: 100 }, { x: 100, y: 0 }, 45], + ])('should narmalize gradient angle', (from, to, expected) => { + expect(normalizeGradientAngle(from, to)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/names.test.js b/exporters/js/src/js/normalizers/__tests__/names.test.js new file mode 100644 index 0000000000..ff12a586f2 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/names.test.js @@ -0,0 +1,36 @@ +import { slugifyName, kebabCaseToPascalCase, kebabCaseToCamelCase } from '../names'; + +describe('slugifyName', () => { + it.each([ + ['test', 'test'], + ['test--test', 'test-test'], + ['test test', 'test-test'], + ['test--12--TEST', 'test-test'], + ['Text Primary', 'text-primary'], + ['Text/Primary', 'text-primary'], + ['Text--Primary', 'text-primary'], + ['Text-01-Primary', 'text-primary'], + ])('should slugify name correctly', (name, expected) => { + expect(slugifyName(name)).toBe(expected); + }); +}); + +describe('kebabCaseToPascalCase', () => { + it.each([ + ['test', 'Test'], + ['test-test', 'TestTest'], + ['text-primary', 'TextPrimary'], + ])('should convert kebab case to pascal case', (name, expected) => { + expect(kebabCaseToPascalCase(name)).toBe(expected); + }); +}); + +describe('kebabCaseToCamelCase', () => { + it.each([ + ['test', 'test'], + ['test-test', 'testTest'], + ['text-primary', 'textPrimary'], + ])('should convert kebab case to camel case', (name, expected) => { + expect(kebabCaseToCamelCase(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/plural.test.js b/exporters/js/src/js/normalizers/__tests__/plural.test.js new file mode 100644 index 0000000000..b80406dc01 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/plural.test.js @@ -0,0 +1,10 @@ +import { plural } from '../plural'; + +describe('plural', () => { + it.each([ + // name, expected + ['color', 'colors'], + ])('should pluralize name', (name, expected) => { + expect(plural(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/singular.test.js b/exporters/js/src/js/normalizers/__tests__/singular.test.js new file mode 100644 index 0000000000..a7f3fc31c4 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/singular.test.js @@ -0,0 +1,10 @@ +import { singular } from '../singular'; + +describe('singular', () => { + it.each([ + // name, expected + ['colors', 'color'], + ])('should singularize name', (name, expected) => { + expect(singular(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/color.ts b/exporters/js/src/js/normalizers/color.ts new file mode 100644 index 0000000000..27b18e4b8d --- /dev/null +++ b/exporters/js/src/js/normalizers/color.ts @@ -0,0 +1,16 @@ +export function normalizeColor(color: string): string { + const colorParts = color.match(/.{1,2}/g); + let shortHex = true; + colorParts && + colorParts.forEach((part) => { + if (shortHex) { + shortHex = /^(.)\1+$/.test(part); + } + }); + + if (shortHex) { + return `${color.substring(0, 1)}${color.substring(2, 3)}${color.substring(4, 5)}`; + } + + return color; +} diff --git a/exporters/js/src/js/normalizers/gradients.ts b/exporters/js/src/js/normalizers/gradients.ts new file mode 100644 index 0000000000..2fbd6cd561 --- /dev/null +++ b/exporters/js/src/js/normalizers/gradients.ts @@ -0,0 +1,14 @@ +type AngleShape = { + x: number; + y: number; +}; + +export function normalizeGradientAngle(from: AngleShape, to: AngleShape): number { + const deltaY = to.y - from.y; + const deltaX = to.x - from.x; + const radians = Math.atan2(deltaY, deltaX); + let result = (radians * 180) / Math.PI; + result += 90; + + return (result < 0 ? 360 + result : result) % 360; +} diff --git a/exporters/js/src/js/normalizers/names.ts b/exporters/js/src/js/normalizers/names.ts new file mode 100644 index 0000000000..c14960d8e1 --- /dev/null +++ b/exporters/js/src/js/normalizers/names.ts @@ -0,0 +1,39 @@ +export function slugifyName(name: string): string { + return ( + name + // Replace all white space characters with dashes, `Text Primary` -> `Text-Primary` + .replace(/\s/g, '-') + // Replace all forward slashes with dashes, `Text/Primary` -> `Text-Primary` + .replace(/\//g, '-') + // Replace `dash number number dash` with single dash, `Text-01-Primary` -> `Text-Primary` + .replace(/-\d\d-/g, '-') + // Replace all double dashes with single dashes, `Text--Primary` -> `Text-Primary` + .replace(/--+/g, '-') + // Make all characters lowercase, `Text-Primary` -> `text-primary` + .toLowerCase() + ); +} + +export function kebabCaseToPascalCase(name: string): string { + return ( + name + // Split the string at hyphens + .split('-') + // Capitalize the first letter of each segment + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) + // Join the segments back together + .join('') + ); +} + +export function kebabCaseToCamelCase(name: string): string { + return ( + name + // Split the string at hyphens + .split('-') + // Capitalize the first letter of each segment + .map((segment, index) => (index > 0 ? segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase() : segment)) + // Join the segments back together + .join('') + ); +} diff --git a/exporters/js/src/js/normalizers/plural.ts b/exporters/js/src/js/normalizers/plural.ts new file mode 100644 index 0000000000..51a925e9a4 --- /dev/null +++ b/exporters/js/src/js/normalizers/plural.ts @@ -0,0 +1,10 @@ +export function plural(name: string): string { + if (name === 'radius') { + return 'radii'; + } + if (name.slice(-1) === 's') { + return name; + } + + return `${name}s`; +} diff --git a/exporters/js/src/js/normalizers/singular.ts b/exporters/js/src/js/normalizers/singular.ts new file mode 100644 index 0000000000..a526dea263 --- /dev/null +++ b/exporters/js/src/js/normalizers/singular.ts @@ -0,0 +1,7 @@ +export function singular(name: string): string { + if (name.slice(-1) === 's') { + return name.replace(/.$/, ''); + } + + return name; +} diff --git a/exporters/js/src/js/printers/__tests__/types.test.ts b/exporters/js/src/js/printers/__tests__/types.test.ts new file mode 100644 index 0000000000..85aaaa2819 --- /dev/null +++ b/exporters/js/src/js/printers/__tests__/types.test.ts @@ -0,0 +1,27 @@ +import { printTypes } from '../types'; + +describe('printTypes', () => { + it.each([ + // types, name, expected + [ + { x: ['arnold'], y: ['rimmer'] }, + 'someName', + ` +export const x = { + arnold: xArnold, +}; + +export const y = { + rimmer: yRimmer, +}; + +export const someName = { + x, + y, +}; +`, + ], + ])('should print types', (types, name, expected) => { + expect(printTypes(types, name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/printers/__tests__/unit.test.ts b/exporters/js/src/js/printers/__tests__/unit.test.ts new file mode 100644 index 0000000000..075cd43295 --- /dev/null +++ b/exporters/js/src/js/printers/__tests__/unit.test.ts @@ -0,0 +1,13 @@ +import { printUnit } from '../unit'; + +describe('printUnit', () => { + it.each([ + // value, unit, expected + [123, 'Pixels', '123px'], + [123, 'rem', '123rem'], + [-123, 'Pixels', '-123px'], + [-123, 'rem', '-123rem'], + ])('should print unit', (value, unit, expected) => { + expect(printUnit(value, unit)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/printers/types.ts b/exporters/js/src/js/printers/types.ts new file mode 100644 index 0000000000..c03419b94b --- /dev/null +++ b/exporters/js/src/js/printers/types.ts @@ -0,0 +1,25 @@ +import { kebabCaseToCamelCase, kebabCaseToPascalCase } from '../normalizers/names'; + +export function printTypes(types: Record, name: string) { + let result = ''; + const keys: string[] = []; + Object.entries(types).forEach(([key, value]) => { + const typeName = kebabCaseToCamelCase(key); + keys.push(typeName); + if (value.length > 0) { + result = `${result}\nexport const ${typeName} = { +${value + .map((val: string) => ` ${kebabCaseToCamelCase(val)}: ${kebabCaseToCamelCase(key)}${kebabCaseToPascalCase(val)},`) + .join('\n')} +};\n`; + } + }); + + if (keys.length > 0) { + result = `${result}\nexport const ${name} = { + ${keys.join(',\n ')}, +};\n`; + } + + return result; +} diff --git a/exporters/js/src/js/printers/unit.ts b/exporters/js/src/js/printers/unit.ts new file mode 100644 index 0000000000..1c295b80af --- /dev/null +++ b/exporters/js/src/js/printers/unit.ts @@ -0,0 +1,13 @@ +export function printUnit(value: number, unit: string): string { + let result = value.toString(); + if (+value !== 0) { + if (unit === 'Pixels') { + result += 'px'; + } + if (unit === 'rem') { + result += 'rem'; + } + } + + return result; +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token1.json b/exporters/js/src/js/sorters/__fixtures__/token1.json new file mode 100644 index 0000000000..81d697a2da --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token1.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "a", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "a" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token10.json b/exporters/js/src/js/sorters/__fixtures__/token10.json new file mode 100644 index 0000000000..cceea50535 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token10.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token2.json b/exporters/js/src/js/sorters/__fixtures__/token2.json new file mode 100644 index 0000000000..2bbf013f06 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token2.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "b", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "b" + }, + "value": { + "text": "20" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token3.json b/exporters/js/src/js/sorters/__fixtures__/token3.json new file mode 100644 index 0000000000..61e9b63eb4 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token3.json @@ -0,0 +1,14 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "20", + "description": "20", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Base/Pink/20" + }, + "value": { + "text": "30" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token4.json b/exporters/js/src/js/sorters/__fixtures__/token4.json new file mode 100644 index 0000000000..60a6e66c60 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token4.json @@ -0,0 +1,14 @@ +{ + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "10", + "description": "10", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Base/Pink/10" + }, + "value": { + "text": "40" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token5.json b/exporters/js/src/js/sorters/__fixtures__/token5.json new file mode 100644 index 0000000000..c7d699b6ea --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token5.json @@ -0,0 +1,9 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "display", + "description": "20", + "tokenType": "Color", + "value": { + "text": "50" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token6.json b/exporters/js/src/js/sorters/__fixtures__/token6.json new file mode 100644 index 0000000000..cdfe5fcc12 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token6.json @@ -0,0 +1,9 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "color", + "description": "20", + "tokenType": "Color", + "value": { + "text": "60" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token7.json b/exporters/js/src/js/sorters/__fixtures__/token7.json new file mode 100644 index 0000000000..cceea50535 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token7.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token8.json b/exporters/js/src/js/sorters/__fixtures__/token8.json new file mode 100644 index 0000000000..68132903df --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token8.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-tablet", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-tablet" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token9.json b/exporters/js/src/js/sorters/__fixtures__/token9.json new file mode 100644 index 0000000000..3e5e9adb91 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token9.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "grid-gutter-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "grid-gutter-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts b/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts new file mode 100644 index 0000000000..160d00bbd5 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts @@ -0,0 +1,18 @@ +import { breakpointSort } from '../breakpointSort'; +import token7 from '../__fixtures__/token7.json'; +import token8 from '../__fixtures__/token8.json'; +import token9 from '../__fixtures__/token9.json'; +import token10 from '../__fixtures__/token10.json'; + +describe('breakpointSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token7, token8, 'mobile,tablet,desktop', false, 1], + [token9, token10, 'mobile,tablet,desktop', false, 1], + ])('should sort tokens based on locale', (tokenA, tokenB, breakpointsString, sortByValue, expected) => { + expect(breakpointSort(tokenA, tokenB, breakpointsString, sortByValue)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/__tests__/localeSort.test.ts b/exporters/js/src/js/sorters/__tests__/localeSort.test.ts new file mode 100644 index 0000000000..9f96154f78 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/localeSort.test.ts @@ -0,0 +1,21 @@ +import { localeSort } from '../localeSort'; +import token1 from '../__fixtures__/token1.json'; +import token2 from '../__fixtures__/token2.json'; +import token3 from '../__fixtures__/token3.json'; +import token4 from '../__fixtures__/token4.json'; +import token5 from '../__fixtures__/token5.json'; +import token6 from '../__fixtures__/token6.json'; + +describe('localeSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token1, token2, -1], + [token3, token4, 1], + [token5, token6, 1], + ])('should sort tokens based on locale', (tokenA, tokenB, expected) => { + expect(localeSort(tokenA, tokenB)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/__tests__/valueSort.test.ts b/exporters/js/src/js/sorters/__tests__/valueSort.test.ts new file mode 100644 index 0000000000..57bf8cb827 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/valueSort.test.ts @@ -0,0 +1,21 @@ +import { valueSort } from '../valueSort'; +import token1 from '../__fixtures__/token1.json'; +import token2 from '../__fixtures__/token2.json'; +import token3 from '../__fixtures__/token3.json'; +import token4 from '../__fixtures__/token4.json'; +import token5 from '../__fixtures__/token5.json'; +import token6 from '../__fixtures__/token6.json'; + +describe('valueSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token1, token2, -10], + [token4, token3, 10], + [token5, token6, -10], + ])('should sort tokens based on locale', (tokenA, tokenB, expected) => { + expect(valueSort(tokenA, tokenB)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/breakpointSort.ts b/exporters/js/src/js/sorters/breakpointSort.ts new file mode 100644 index 0000000000..94731512be --- /dev/null +++ b/exporters/js/src/js/sorters/breakpointSort.ts @@ -0,0 +1,31 @@ +import { Token } from '../index'; +import { localeSort } from './localeSort'; + +export function breakpointSort(a: Token, b: Token, breakpointsString: string, sortByValue: boolean) { + const breakpoints = breakpointsString.trim().split(','); + let aBreakpoint = ''; + let bBreakpoint = ''; + breakpoints.some((substring: string) => { + if ((a.origin ? a.origin.name : a.name).includes(substring)) { + aBreakpoint = substring; + } + + if ((b.origin ? b.origin.name : b.name).includes(substring)) { + bBreakpoint = substring; + } + + return false; + }); + + if (!!sortByValue && !aBreakpoint) { + return -1; + } + + let result = breakpoints.indexOf(aBreakpoint) - breakpoints.indexOf(bBreakpoint); + + if (result === 0) { + result = localeSort(a, b); + } + + return result; +} diff --git a/exporters/js/src/js/sorters/localeSort.ts b/exporters/js/src/js/sorters/localeSort.ts new file mode 100644 index 0000000000..0f3a0867d6 --- /dev/null +++ b/exporters/js/src/js/sorters/localeSort.ts @@ -0,0 +1,9 @@ +import { slugifyName } from '../normalizers/names'; +import type { Token } from '../index'; + +export function localeSort(a: Token, b: Token) { + const aCompare = slugifyName(a.origin ? a.origin.name : a.name); + const bCompare = slugifyName(b.origin ? b.origin.name : b.name); + + return aCompare.localeCompare(bCompare); +} diff --git a/exporters/js/src/js/sorters/valueSort.ts b/exporters/js/src/js/sorters/valueSort.ts new file mode 100644 index 0000000000..b4f3d94f79 --- /dev/null +++ b/exporters/js/src/js/sorters/valueSort.ts @@ -0,0 +1,7 @@ +import type { Token } from '../index'; + +export function valueSort(a: Token, b: Token) { + // Value is defined as `Record;` + // @ts-expect-error TS2571: Object is of type 'unknown'. + return +a.value.text - +b.value.text; +} diff --git a/exporters/js/src/measures.pr b/exporters/js/src/measures.pr new file mode 100644 index 0000000000..f36e343b88 --- /dev/null +++ b/exporters/js/src/measures.pr @@ -0,0 +1,4 @@ +// Generated Measures from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Measure", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("measures", ds.tokensByType("Measure", ds.currentBrand().id), [], true, false, '', 'space') }} +{[/]} diff --git a/exporters/js/src/other.pr b/exporters/js/src/other.pr new file mode 100644 index 0000000000..4ddd246b48 --- /dev/null +++ b/exporters/js/src/other.pr @@ -0,0 +1,5 @@ +// Generated Generic Tokens (Other) from Supernova. Do not edit manually. +{[ if (ds.tokensByType("GenericToken", ds.currentBrand().id)).count() > 0 ]} +{[ let breakpoints = exportConfiguration().breakpoints /]} +{{ generateSimple("other", ds.tokensByType("GenericToken", ds.currentBrand().id), ds.tokenGroupsOfType("GenericToken", ds.currentBrand().id), false, true, breakpoints) }} +{[/]} diff --git a/exporters/js/src/radii.pr b/exporters/js/src/radii.pr new file mode 100644 index 0000000000..9b6ee7bb29 --- /dev/null +++ b/exporters/js/src/radii.pr @@ -0,0 +1,4 @@ +// Generated Radii from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Measure", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("radii", ds.tokensByType("Measure", ds.currentBrand().id), [], true, false, '', 'radius') }} +{[/]} diff --git a/exporters/js/src/shadows.pr b/exporters/js/src/shadows.pr new file mode 100644 index 0000000000..6c59746f22 --- /dev/null +++ b/exporters/js/src/shadows.pr @@ -0,0 +1,4 @@ +// Generated Shadows from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Shadow", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("shadows", ds.tokensByType("Shadow", ds.currentBrand().id), ds.tokenGroupsOfType("Shadow", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/typography.pr b/exporters/js/src/typography.pr new file mode 100644 index 0000000000..a0c94db92f --- /dev/null +++ b/exporters/js/src/typography.pr @@ -0,0 +1,7 @@ +// Generated Typography from Supernova. Do not edit manually. +{[ let defaultFontSize = exportConfiguration().defaultFontSize /]} +{[ let fontFamilyFallback = exportConfiguration().fontFamilyFallback /]} +{[ let breakpoints = exportConfiguration().breakpoints /]} +{[ if (ds.tokensByType("Typography", ds.currentBrand().id)).count() > 0 ]} +{{ generateTypography(ds.tokensByType("Typography", ds.currentBrand().id), defaultFontSize, fontFamilyFallback, breakpoints) }} +{[/]} diff --git a/exporters/js/tsconfig.eslint.json b/exporters/js/tsconfig.eslint.json new file mode 100644 index 0000000000..dc13a26063 --- /dev/null +++ b/exporters/js/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./", "./.eslintrc.js"] +} diff --git a/exporters/js/tsconfig.json b/exporters/js/tsconfig.json new file mode 100644 index 0000000000..47838b39ae --- /dev/null +++ b/exporters/js/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "sourceMap": true, + "declaration": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "es2015", + "target": "es2015", + "noEmit": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "allowUnreachableCode": false, + "allowSyntheticDefaultImports": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "strictFunctionTypes": true, + "noImplicitAny": true, + "esModuleInterop": true, + "typeRoots": ["../../node_modules/@types"], + "lib": ["es2015", "dom", "dom.iterable"], + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "include": ["./src/**/*"], + "exclude": ["./node_modules", "./dist/**/*"] +} diff --git a/exporters/js/vite.config.ts b/exporters/js/vite.config.ts new file mode 100644 index 0000000000..c458d27a99 --- /dev/null +++ b/exporters/js/vite.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/js/index.ts'), + name: 'Functions', + fileName: 'functions', + formats: ['cjs'], + }, + rollupOptions: { + output: { + dir: resolve(__dirname, './generated'), + banner: + '/**\n * THIS FILE IS GENERATED USING `build` SCRIPT\n * DO NOT EDIT MANUALLY\n * SEE CONTRIBUTING.md\n*/', + }, + }, + }, +});