diff --git a/packages/core/src/lib/config/types.ts b/packages/core/src/lib/config/types.ts index 4257840..64bf368 100644 --- a/packages/core/src/lib/config/types.ts +++ b/packages/core/src/lib/config/types.ts @@ -3,7 +3,7 @@ import { parseUserConfig } from './userConfig' // Types export interface AssetConfig { output: string - format: 'css' | 'json' + format: 'css' | 'scss' | 'json' } export interface ColorsConfig extends AssetConfig {} diff --git a/packages/core/src/lib/config/userConfig.ts b/packages/core/src/lib/config/userConfig.ts index 682500a..6bb743f 100644 --- a/packages/core/src/lib/config/userConfig.ts +++ b/packages/core/src/lib/config/userConfig.ts @@ -33,7 +33,11 @@ const outputPathSchema = z.string({ required_error: '.output is required', invalid_type_error: '.output must be a string', }) -const outputFormatSchema = z.union([z.literal('css'), z.literal('json')]) +const outputFormatSchema = z.union([ + z.literal('css'), + z.literal('scss'), + z.literal('json'), +]) const assetConfigSchema = z.object({ output: outputPathSchema, diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 6e9d9c4..dcfd47c 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -11,7 +11,7 @@ import { normalizePenpotExportUserConfig, AssetConfig, } from './config' -import { writeJsonFile, writeCssFile } from './outputters' +import { writeJsonFile, writeCssFile, writeScssFile } from './outputters' import { CSSClassDefinition, CSSCustomPropertyDefinition } from './types' const processOutput = ({ @@ -26,6 +26,9 @@ const processOutput = ({ if (outputFormat === 'css') { return writeCssFile(outputPath, content) } + if (outputFormat === 'scss') { + return writeScssFile(outputPath, content) + } if (outputFormat === 'json') { return writeJsonFile(outputPath, content) } diff --git a/packages/core/src/lib/outputters/index.ts b/packages/core/src/lib/outputters/index.ts index 0ce4ce0..708143c 100644 --- a/packages/core/src/lib/outputters/index.ts +++ b/packages/core/src/lib/outputters/index.ts @@ -1,2 +1,3 @@ export * from './css' +export * from './scss' export * from './json' diff --git a/packages/core/src/lib/outputters/scss/index.ts b/packages/core/src/lib/outputters/scss/index.ts new file mode 100644 index 0000000..99ef89c --- /dev/null +++ b/packages/core/src/lib/outputters/scss/index.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + CSSClassDefinition, + CSSCustomPropertyDefinition, + isCssClassDefinition, +} from '../../types' + +import { camelToKebab } from '../css/syntax' + +import { textToScssVariableName } from './syntax' + +const areCssCustomPropertiesDefinitions = ( + objects: Array, +): objects is Array => { + return !objects.every(isCssClassDefinition) +} + +/** + * From: https://sass-lang.com/documentation/values/maps/ + * Most of the time, it’s a good idea to use quoted strings rather than unquoted strings for map keys. This is because + * some values, such as color names, may look like unquoted strings but actually be other types. To avoid confusing + * problems down the line, just use quotes! + */ +const serializeScssMap = (cssCustomProperty: CSSClassDefinition) => { + const mapName = textToScssVariableName(cssCustomProperty.name) + const mapPairs = Object.entries(cssCustomProperty.cssProps).map( + ([key, value]) => ` "${camelToKebab(key)}": ${value},`, + ) + return [`${mapName}: (`, ...mapPairs, `);`].join('\n') +} + +const serializeScssVariable = ( + cssCustomProperty: CSSCustomPropertyDefinition, +): string => { + const { name, value } = cssCustomProperty + + const property = textToScssVariableName(name) + return `${property}: ${value};` +} + +const serializeScss = ( + cssDefinitions: CSSClassDefinition[] | CSSCustomPropertyDefinition[], +): string => { + if (areCssCustomPropertiesDefinitions(cssDefinitions)) { + const cssDeclarations = cssDefinitions.map((customPropertyDefinition) => + serializeScssVariable(customPropertyDefinition), + ) + return cssDeclarations.join('\n') + } else { + return cssDefinitions.map(serializeScssMap).join('\n\n') + } +} + +export function writeScssFile( + outputPath: string, + cssDefinitions: CSSClassDefinition[] | CSSCustomPropertyDefinition[], +) { + const css = serializeScss(cssDefinitions) + const dirname = path.dirname(outputPath) + + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }) + } + + fs.writeFileSync(outputPath, css, 'utf-8') +} diff --git a/packages/core/src/lib/outputters/scss/syntax.ts b/packages/core/src/lib/outputters/scss/syntax.ts new file mode 100644 index 0000000..383e189 --- /dev/null +++ b/packages/core/src/lib/outputters/scss/syntax.ts @@ -0,0 +1,12 @@ +import { textToCssIdentToken } from '../css/syntax' + +/** + * From: https://sass-lang.com/documentation/variables/ + * Sass variables, like all Sass identifiers, treat hyphens and underscores as identical. This means that $font-size and + * $font_size both refer to the same variable. + */ +export const textToScssVariableName = (str: string) => { + // NOTE We can't avoid name clashing in this case, but at least let's make it explicit for a reader. + const sassIdent = textToCssIdentToken(str.replace(/_/g, '-')) + return '$' + sassIdent +} diff --git a/packages/demo/penpot-export.config.js b/packages/demo/penpot-export.config.js index 017cdf8..3a9c193 100644 --- a/packages/demo/penpot-export.config.js +++ b/packages/demo/penpot-export.config.js @@ -32,6 +32,10 @@ const config = { { output: 'src/styles/colors.css', // πŸ‘ˆ Path where your CSS file should be generated. }, + { + output: 'src/styles/colors.scss', // πŸ‘ˆ Path where your SCSS file should be generated. + format: 'scss', + }, { output: 'src/styles/colors.json', // πŸ‘ˆ Path where your JSON file should be generated. format: 'json', @@ -41,6 +45,10 @@ const config = { { output: 'src/styles/typographies.css', // πŸ‘ˆ Path where your CSS file should be generated. }, + { + output: 'src/styles/typographies.scss', // πŸ‘ˆ Path where your SCSS file should be generated. + format: 'scss', + }, { output: 'src/styles/typographies.json', // πŸ‘ˆ Path where your JSON file should be generated. format: 'json',