From 660f2b9ae99c067234a9fa4d2d4d12e8e1db9161 Mon Sep 17 00:00:00 2001 From: Roberto Redradix Date: Wed, 13 Sep 2023 10:20:29 +0200 Subject: [PATCH] refactor(core): decouple name scoping logic from API adapters Introduce the concept of transformer functions. A transformer function will receive an assets as an input and will output the same kind of asset. Scoping class names in CSS was a transformation applied to typography and page components assets when the output is CSS. Decoupling this transformation from the rest of the adapter results in cleaner API inbound adapters, without any project-specific logic, so now they can be co-located close to the PenpotClient code. --- .../inbound/typographiesToCssClasses.ts | 35 -------- .../inbound/typographiesToFontSummary.ts | 36 --------- .../adapters/colors.ts} | 19 +++-- .../adapters/pageComponents.ts} | 22 +++--- .../core/src/lib/api/adapters/typographies.ts | 79 +++++++++++++++++++ packages/core/src/lib/api/index.ts | 4 + packages/core/src/lib/index.ts | 58 ++++++++------ packages/core/src/lib/outputters/css/index.ts | 8 +- packages/core/src/lib/outputters/json.ts | 58 ++++++++++++-- packages/core/src/lib/transformers/index.ts | 2 + .../src/lib/transformers/scopeClassNames.ts | 40 ++++++++++ packages/core/src/lib/transformers/types.ts | 5 ++ packages/core/src/lib/types.ts | 3 +- 13 files changed, 245 insertions(+), 124 deletions(-) delete mode 100644 packages/core/src/lib/adapters/inbound/typographiesToCssClasses.ts delete mode 100644 packages/core/src/lib/adapters/inbound/typographiesToFontSummary.ts rename packages/core/src/lib/{adapters/inbound/colorsToCssVariables.ts => api/adapters/colors.ts} (68%) rename packages/core/src/lib/{adapters/inbound/pageComponentsToCssClasses.ts => api/adapters/pageComponents.ts} (76%) create mode 100644 packages/core/src/lib/api/adapters/typographies.ts create mode 100644 packages/core/src/lib/transformers/index.ts create mode 100644 packages/core/src/lib/transformers/scopeClassNames.ts create mode 100644 packages/core/src/lib/transformers/types.ts diff --git a/packages/core/src/lib/adapters/inbound/typographiesToCssClasses.ts b/packages/core/src/lib/adapters/inbound/typographiesToCssClasses.ts deleted file mode 100644 index 599aa4e..0000000 --- a/packages/core/src/lib/adapters/inbound/typographiesToCssClasses.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PenpotApiTypography, CssTextProperty, PenpotApiFile } from '../../api' -import { CSSClassDefinition } from '../../types' - -const getTypographyAssetCssProps = ( - typography: PenpotApiTypography, -): Record => { - return { - lineHeight: typography.lineHeight, - fontStyle: typography.fontStyle, - textTransform: typography.textTransform, - fontWeight: typography.fontWeight, - fontSize: `${typography.fontSize}px`, - letterSpacing: `${typography.letterSpacing}px`, - fontFamily: `"${typography.fontFamily}"`, - } -} - -export const adaptTypographiesToCssClassDefinitions = ( - penpotFile: PenpotApiFile, -): CSSClassDefinition[] => { - const fileName = penpotFile.name - const typographies = Object.values(penpotFile.data.typographies ?? {}) - - const cssClassDefinitions = typographies.map((typography) => { - const cssProps = getTypographyAssetCssProps(typography) - - return { - scope: fileName, - name: typography.name, - cssProps, - } - }) - - return cssClassDefinitions -} diff --git a/packages/core/src/lib/adapters/inbound/typographiesToFontSummary.ts b/packages/core/src/lib/adapters/inbound/typographiesToFontSummary.ts deleted file mode 100644 index 57ae7fc..0000000 --- a/packages/core/src/lib/adapters/inbound/typographiesToFontSummary.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PenpotApiFile } from '../../api' -import { FontsSummary } from '../../types' - -export const summarizeTypographies = ( - penpotFile: PenpotApiFile, -): FontsSummary => { - const typographies = Object.values(penpotFile.data.typographies ?? {}) - - const separator = '|>' - const dedupedFontsKeys = Array.from( - typographies.reduce((set, typography) => { - const { fontId, fontFamily, fontWeight } = typography - const typographyKey = [fontId, fontFamily, fontWeight].join(separator) - - set.add(typographyKey) - return set - }, new Set()), - ) - - const fontsSummary = dedupedFontsKeys.reduce( - (summary, typographyKey) => { - const [fontId, fontFamily, fontWeight] = typographyKey.split(separator) - const fontSource = fontId.startsWith('gfont-') - ? 'googleFonts' - : 'userCustomFonts' - - summary[fontSource][fontFamily] ??= [] - summary[fontSource][fontFamily].push(fontWeight) - - return summary - }, - { googleFonts: {}, userCustomFonts: {} }, - ) - - return fontsSummary -} diff --git a/packages/core/src/lib/adapters/inbound/colorsToCssVariables.ts b/packages/core/src/lib/api/adapters/colors.ts similarity index 68% rename from packages/core/src/lib/adapters/inbound/colorsToCssVariables.ts rename to packages/core/src/lib/api/adapters/colors.ts index cd640eb..45ce861 100644 --- a/packages/core/src/lib/adapters/inbound/colorsToCssVariables.ts +++ b/packages/core/src/lib/api/adapters/colors.ts @@ -1,5 +1,6 @@ -import { PenpotApiFile } from '../../api' -import { CSSCustomPropertyDefinition } from '../../types' +import { CSSCustomPropertyDefinition, ColorAssets } from '../../types' + +import { PenpotApiFile } from '../types' const toHexQuartet = (hexTriplet: string, alpha: number = 1) => { const alphaChannel = Math.trunc(alpha * 0x100) - 1 @@ -20,19 +21,21 @@ const toHexQuartet = (hexTriplet: string, alpha: number = 1) => { return ('#' + RR + GG + BB + AA).toLowerCase() } -export const adaptColorsToCssVariables = ( - penpotFile: PenpotApiFile, -): CSSCustomPropertyDefinition[] => { +const adaptColorsToCssVariables = (penpotFile: PenpotApiFile): ColorAssets => { const fileName = penpotFile.name const colors = Object.values(penpotFile.data.colors ?? {}) - const cssPropsEntries = colors.map((color) => { + const cssPropsEntries = colors.map((color) => { return { - scope: fileName, name: color.name, value: toHexQuartet(color.color, color.opacity), } }) - return cssPropsEntries + return { + scope: fileName, + colors: cssPropsEntries, + } } + +export default adaptColorsToCssVariables diff --git a/packages/core/src/lib/adapters/inbound/pageComponentsToCssClasses.ts b/packages/core/src/lib/api/adapters/pageComponents.ts similarity index 76% rename from packages/core/src/lib/adapters/inbound/pageComponentsToCssClasses.ts rename to packages/core/src/lib/api/adapters/pageComponents.ts index 1b4f8d9..2cdeea4 100644 --- a/packages/core/src/lib/adapters/inbound/pageComponentsToCssClasses.ts +++ b/packages/core/src/lib/api/adapters/pageComponents.ts @@ -1,11 +1,11 @@ +import { CSSClassDefinition, PageComponentAssets } from '../../types' + import { getObjectShapesFromPage, isComponent, pickObjectProps, -} from '../../api/helpers' - -import { PenpotApiFile, PenpotApiObject } from '../../api' -import { CSSClassDefinition } from '../../types' +} from '../helpers' +import { PenpotApiFile, PenpotApiObject } from '../types' const extractObjectCssProps = (object: PenpotApiObject) => { let { textDecoration, ...styles } = object.positionData[0] @@ -33,10 +33,10 @@ const getTextObjectCssProps = (object: PenpotApiObject) => { return pickObjectProps(objectCssProps, textCssProps) } -export const adaptPageComponentsToCssClassDefinitions = ( +const adaptPageComponentsToCssClassDefinitions = ( penpotFile: PenpotApiFile, options: { pageId: string }, -): CSSClassDefinition[] => { +): PageComponentAssets => { const pages = penpotFile.data.pagesIndex ?? {} const page = pages[options.pageId] const pageObjects = Object.values(page.objects) @@ -44,14 +44,13 @@ export const adaptPageComponentsToCssClassDefinitions = ( .filter(isComponent) .map((object) => getObjectShapesFromPage(object, page)) - const cssClassDefinitions = [] + const cssClassDefinitions: CSSClassDefinition[] = [] for (const component of components) { for (const objectId in component.objects) { const object = component.objects[objectId] if (object.type === 'text') { const cssProps = getTextObjectCssProps(object) cssClassDefinitions.push({ - scope: page.name, name: object.name, cssProps, }) @@ -59,5 +58,10 @@ export const adaptPageComponentsToCssClassDefinitions = ( } } - return cssClassDefinitions + return { + scope: page.name, + pageComponents: cssClassDefinitions, + } } + +export default adaptPageComponentsToCssClassDefinitions diff --git a/packages/core/src/lib/api/adapters/typographies.ts b/packages/core/src/lib/api/adapters/typographies.ts new file mode 100644 index 0000000..5bcd526 --- /dev/null +++ b/packages/core/src/lib/api/adapters/typographies.ts @@ -0,0 +1,79 @@ +import { CSSClassDefinition, FontsSummary, TypographyAssets } from '../../types' + +import { PenpotApiTypography, CssTextProperty, PenpotApiFile } from '../types' + +const mapTypographyAssetCssProps = ( + typography: PenpotApiTypography, +): Record => { + return { + lineHeight: typography.lineHeight, + fontStyle: typography.fontStyle, + textTransform: typography.textTransform, + fontWeight: typography.fontWeight, + fontSize: `${typography.fontSize}px`, + letterSpacing: `${typography.letterSpacing}px`, + fontFamily: `"${typography.fontFamily}"`, + } +} + +const adaptTypographiesToCssClassDefinitions = ( + typographies: PenpotApiTypography[], +): CSSClassDefinition[] => { + const cssClassDefinitions = typographies.map( + (typography) => { + const cssProps = mapTypographyAssetCssProps(typography) + + return { + name: typography.name, + cssProps, + } + }, + ) + + return cssClassDefinitions +} + +const summarizeTypographies = ( + typographies: PenpotApiTypography[], +): FontsSummary => { + const separator = '|>' + const dedupedFontsKeys = Array.from( + typographies.reduce((set, typography) => { + const { fontId, fontFamily, fontWeight } = typography + const typographyKey = [fontId, fontFamily, fontWeight].join(separator) + + set.add(typographyKey) + return set + }, new Set()), + ) + + const fontsSummary = dedupedFontsKeys.reduce( + (summary, typographyKey) => { + const [fontId, fontFamily, fontWeight] = typographyKey.split(separator) + const fontSource = fontId.startsWith('gfont-') + ? 'googleFonts' + : 'userCustomFonts' + + summary[fontSource][fontFamily] ??= [] + summary[fontSource][fontFamily].push(fontWeight) + + return summary + }, + { googleFonts: {}, userCustomFonts: {} }, + ) + + return fontsSummary +} + +const adaptTypographies = (penpotFile: PenpotApiFile): TypographyAssets => { + const fileName = penpotFile.name + const typographies = Object.values(penpotFile.data.typographies ?? {}) + + return { + scope: fileName, + typographies: adaptTypographiesToCssClassDefinitions(typographies), + typographiesSummary: summarizeTypographies(typographies), + } +} + +export default adaptTypographies diff --git a/packages/core/src/lib/api/index.ts b/packages/core/src/lib/api/index.ts index ff26f64..3148a38 100644 --- a/packages/core/src/lib/api/index.ts +++ b/packages/core/src/lib/api/index.ts @@ -1,2 +1,6 @@ +export { default as adaptColors } from './adapters/colors' +export { default as adaptTypographies } from './adapters/typographies' +export { default as adaptPageComponents } from './adapters/pageComponents' + export * from './types' export { Penpot as default } from './penpot' diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 066d0a2..7dbf99c 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -1,12 +1,11 @@ import path from 'node:path' -import { adaptTypographiesToCssClassDefinitions } from './adapters/inbound/typographiesToCssClasses' -import { summarizeTypographies } from './adapters/inbound/typographiesToFontSummary' -import { adaptColorsToCssVariables } from './adapters/inbound/colorsToCssVariables' -import { adaptPageComponentsToCssClassDefinitions } from './adapters/inbound/pageComponentsToCssClasses' - -import { Penpot } from './api/penpot' - +import { + default as PenpotApiClient, + adaptColors, + adaptTypographies, + adaptPageComponents, +} from './api' import { parseUserConfig, normalizePenpotExportUserConfig, @@ -20,17 +19,24 @@ import { jsonOutputter, OutputterFunction, } from './outputters' +import { + scopeTypographiesClassNames, + scopePageComponentsClassNames, + TransformerFunction, +} from './transformers' import { PenpotExportAssets } from './types' -const processOutput = ({ - outputFormat = 'css', +function processOutput({ + outputFormat, outputPath, assets, + transform, }: { outputFormat: AssetConfig['format'] outputPath: string - assets: PenpotExportAssets -}) => { + assets: T + transform?: TransformerFunction +}) { const outputter: OutputterFunction | null = outputFormat === 'css' ? cssOutputter @@ -43,7 +49,8 @@ const processOutput = ({ if (outputter === null) throw new PenpotExportInternalError('Unable to process output format') - const textContents = outputter(assets) + const transformedAssets = transform !== undefined ? transform(assets) : assets + const textContents = outputter(transformedAssets) return writeTextFile(outputPath, textContents) } @@ -54,7 +61,7 @@ export default async function penpotExport( const parsedUserConfig = parseUserConfig(userConfig) const config = normalizePenpotExportUserConfig(parsedUserConfig) - const penpot = new Penpot({ + const penpot = new PenpotApiClient({ baseUrl: config.instance, accessToken: config.accessToken, }) @@ -70,9 +77,7 @@ export default async function penpotExport( processOutput({ outputFormat: colorsConfig.format, outputPath: path.resolve(rootProjectPath, colorsConfig.output), - assets: { - colors: adaptColorsToCssVariables(penpotFile), - }, + assets: adaptColors(penpotFile), }) console.log('✅ Colors: %s', colorsConfig.output) @@ -82,10 +87,11 @@ export default async function penpotExport( processOutput({ outputFormat: typographiesConfig.format, outputPath: path.resolve(rootProjectPath, typographiesConfig.output), - assets: { - typographies: adaptTypographiesToCssClassDefinitions(penpotFile), - typographiesSummary: summarizeTypographies(penpotFile), - }, + assets: adaptTypographies(penpotFile), + transform: + typographiesConfig.format === 'css' + ? scopeTypographiesClassNames + : undefined, }) console.log('✅ Typographies: %s', typographiesConfig.output) @@ -95,11 +101,13 @@ export default async function penpotExport( processOutput({ outputFormat: pagesConfig.format, outputPath: path.resolve(rootProjectPath, pagesConfig.output), - assets: { - pageComponents: adaptPageComponentsToCssClassDefinitions(penpotFile, { - pageId: pagesConfig.pageId, - }), - }, + assets: adaptPageComponents(penpotFile, { + pageId: pagesConfig.pageId, + }), + transform: + pagesConfig.format === 'css' + ? scopePageComponentsClassNames + : undefined, }) console.log('✅ Page components: %s', pagesConfig.output) diff --git a/packages/core/src/lib/outputters/css/index.ts b/packages/core/src/lib/outputters/css/index.ts index 05592d1..16392ec 100644 --- a/packages/core/src/lib/outputters/css/index.ts +++ b/packages/core/src/lib/outputters/css/index.ts @@ -18,9 +18,8 @@ import { } from './syntax' const serializeCssClass = (cssClassDefinition: CSSClassDefinition): string => { - const selector = textToCssClassSelector( - `${cssClassDefinition.scope}--${cssClassDefinition.name}`, - ) + const selector = textToCssClassSelector(cssClassDefinition.name) + const cssValidProps = Object.keys(cssClassDefinition.cssProps).map( (key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`, ) @@ -56,6 +55,7 @@ const composeFileHeader = (fontsSummary: FontsSummary) => { } const serializeCss: OutputterFunction = ({ + scope, colors, typographies, typographiesSummary, @@ -64,7 +64,7 @@ const serializeCss: OutputterFunction = ({ if (colors) { return serializeCssCustomPropertiesRoot(colors) } else if (typographies) { - const body = typographies.map(serializeCssClass).join('\n\n') + const body: string = typographies.map(serializeCssClass).join('\n\n') const header: string = composeFileHeader(typographiesSummary) return header + '\n\n' + body diff --git a/packages/core/src/lib/outputters/json.ts b/packages/core/src/lib/outputters/json.ts index dfda9ef..2b637e2 100644 --- a/packages/core/src/lib/outputters/json.ts +++ b/packages/core/src/lib/outputters/json.ts @@ -1,18 +1,66 @@ -import { ColorAssets, PageComponentAssets, TypographyAssets } from '../types' +import { + CSSClassDefinition, + CSSCustomPropertyDefinition, + ColorAssets, + PageComponentAssets, + TypographyAssets, +} from '../types' import { PenpotExportInvalidAssetsError } from './errors' import { OutputterFunction } from './types' +interface JSONCustomPropertyDefinition extends CSSCustomPropertyDefinition { + scope: string +} + +interface JSONClassDefinition extends CSSClassDefinition { + scope: string +} + +const appendScopeToCustomProperties = ( + cssCustomPropertiesDefinitions: CSSCustomPropertyDefinition[], + scope: string, +): JSONCustomPropertyDefinition[] => { + return cssCustomPropertiesDefinitions.map( + (customProperty) => { + return { + scope, + ...customProperty, + } + }, + ) +} + +const appendScopeToClasses = ( + cssClassDefinition: CSSClassDefinition[], + scope: string, +): JSONClassDefinition[] => { + return cssClassDefinition.map((classDefinition) => { + return { + scope, + ...classDefinition, + } + }) +} + const serializeJson: OutputterFunction = ({ + scope, colors, typographies, pageComponents, }: ColorAssets | TypographyAssets | PageComponentAssets): string => { - const assets = colors ?? typographies ?? pageComponents ?? null + if (colors) { + const scopedColors = appendScopeToCustomProperties(colors, scope) + return JSON.stringify(scopedColors, null, 2) + } else if (typographies || pageComponents) { + const scopedAssets = appendScopeToClasses( + typographies ?? pageComponents, + scope, + ) + return JSON.stringify(scopedAssets, null, 2) + } - if (assets === null) throw new PenpotExportInvalidAssetsError() - - return JSON.stringify(assets, null, 2) + throw new PenpotExportInvalidAssetsError() } export default serializeJson diff --git a/packages/core/src/lib/transformers/index.ts b/packages/core/src/lib/transformers/index.ts new file mode 100644 index 0000000..fdebde4 --- /dev/null +++ b/packages/core/src/lib/transformers/index.ts @@ -0,0 +1,2 @@ +export * from './scopeClassNames' +export type { TransformerFunction } from './types' diff --git a/packages/core/src/lib/transformers/scopeClassNames.ts b/packages/core/src/lib/transformers/scopeClassNames.ts new file mode 100644 index 0000000..19dc0bf --- /dev/null +++ b/packages/core/src/lib/transformers/scopeClassNames.ts @@ -0,0 +1,40 @@ +import { + CSSClassDefinition, + PageComponentAssets, + TypographyAssets, +} from '../types' + +const scopeClassNames = ( + cssClassDefinition: CSSClassDefinition[], + scope: string, +): CSSClassDefinition[] => { + return cssClassDefinition.map(({ name, cssProps }) => { + return { + name: `${scope}--${name}`, + cssProps, + } + }) +} + +export function scopeTypographiesClassNames( + assets: TypographyAssets, +): TypographyAssets { + const scopedTypographies = scopeClassNames(assets.typographies, assets.scope) + return { + ...assets, + typographies: scopedTypographies, + } +} + +export function scopePageComponentsClassNames( + assets: PageComponentAssets, +): PageComponentAssets { + const scopedPageComponents = scopeClassNames( + assets.pageComponents, + assets.scope, + ) + return { + ...assets, + pageComponents: scopedPageComponents, + } +} diff --git a/packages/core/src/lib/transformers/types.ts b/packages/core/src/lib/transformers/types.ts new file mode 100644 index 0000000..2332773 --- /dev/null +++ b/packages/core/src/lib/transformers/types.ts @@ -0,0 +1,5 @@ +import { ColorAssets, PageComponentAssets, TypographyAssets } from '../types' + +export type TransformerFunction< + T extends ColorAssets | TypographyAssets | PageComponentAssets, +> = (assets: T) => T diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 1334193..6144a98 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -1,11 +1,9 @@ export interface CSSClassDefinition { - scope: string name: string cssProps: Record } export interface CSSCustomPropertyDefinition { - scope: string name: string value: string } @@ -20,6 +18,7 @@ export interface FontsSummary { } interface BaseAssets { + scope: string colors?: never typographies?: never typographiesSummary?: never