0
Fork 0
mirror of https://github.com/penpot/penpot-export.git synced 2025-01-06 14:50:10 -05:00

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.
This commit is contained in:
Roberto Redradix 2023-09-13 10:20:29 +02:00
parent 07977124da
commit 660f2b9ae9
13 changed files with 245 additions and 124 deletions

View file

@ -1,35 +0,0 @@
import { PenpotApiTypography, CssTextProperty, PenpotApiFile } from '../../api'
import { CSSClassDefinition } from '../../types'
const getTypographyAssetCssProps = (
typography: PenpotApiTypography,
): Record<CssTextProperty, string> => {
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
}

View file

@ -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<string>()),
)
const fontsSummary = dedupedFontsKeys.reduce<FontsSummary>(
(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
}

View file

@ -1,5 +1,6 @@
import { PenpotApiFile } from '../../api' import { CSSCustomPropertyDefinition, ColorAssets } from '../../types'
import { CSSCustomPropertyDefinition } from '../../types'
import { PenpotApiFile } from '../types'
const toHexQuartet = (hexTriplet: string, alpha: number = 1) => { const toHexQuartet = (hexTriplet: string, alpha: number = 1) => {
const alphaChannel = Math.trunc(alpha * 0x100) - 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() return ('#' + RR + GG + BB + AA).toLowerCase()
} }
export const adaptColorsToCssVariables = ( const adaptColorsToCssVariables = (penpotFile: PenpotApiFile): ColorAssets => {
penpotFile: PenpotApiFile,
): CSSCustomPropertyDefinition[] => {
const fileName = penpotFile.name const fileName = penpotFile.name
const colors = Object.values(penpotFile.data.colors ?? {}) const colors = Object.values(penpotFile.data.colors ?? {})
const cssPropsEntries = colors.map((color) => { const cssPropsEntries = colors.map<CSSCustomPropertyDefinition>((color) => {
return { return {
scope: fileName,
name: color.name, name: color.name,
value: toHexQuartet(color.color, color.opacity), value: toHexQuartet(color.color, color.opacity),
} }
}) })
return cssPropsEntries return {
scope: fileName,
colors: cssPropsEntries,
}
} }
export default adaptColorsToCssVariables

View file

@ -1,11 +1,11 @@
import { CSSClassDefinition, PageComponentAssets } from '../../types'
import { import {
getObjectShapesFromPage, getObjectShapesFromPage,
isComponent, isComponent,
pickObjectProps, pickObjectProps,
} from '../../api/helpers' } from '../helpers'
import { PenpotApiFile, PenpotApiObject } from '../types'
import { PenpotApiFile, PenpotApiObject } from '../../api'
import { CSSClassDefinition } from '../../types'
const extractObjectCssProps = (object: PenpotApiObject) => { const extractObjectCssProps = (object: PenpotApiObject) => {
let { textDecoration, ...styles } = object.positionData[0] let { textDecoration, ...styles } = object.positionData[0]
@ -33,10 +33,10 @@ const getTextObjectCssProps = (object: PenpotApiObject) => {
return pickObjectProps(objectCssProps, textCssProps) return pickObjectProps(objectCssProps, textCssProps)
} }
export const adaptPageComponentsToCssClassDefinitions = ( const adaptPageComponentsToCssClassDefinitions = (
penpotFile: PenpotApiFile, penpotFile: PenpotApiFile,
options: { pageId: string }, options: { pageId: string },
): CSSClassDefinition[] => { ): PageComponentAssets => {
const pages = penpotFile.data.pagesIndex ?? {} const pages = penpotFile.data.pagesIndex ?? {}
const page = pages[options.pageId] const page = pages[options.pageId]
const pageObjects = Object.values(page.objects) const pageObjects = Object.values(page.objects)
@ -44,14 +44,13 @@ export const adaptPageComponentsToCssClassDefinitions = (
.filter(isComponent) .filter(isComponent)
.map((object) => getObjectShapesFromPage(object, page)) .map((object) => getObjectShapesFromPage(object, page))
const cssClassDefinitions = [] const cssClassDefinitions: CSSClassDefinition[] = []
for (const component of components) { for (const component of components) {
for (const objectId in component.objects) { for (const objectId in component.objects) {
const object = component.objects[objectId] const object = component.objects[objectId]
if (object.type === 'text') { if (object.type === 'text') {
const cssProps = getTextObjectCssProps(object) const cssProps = getTextObjectCssProps(object)
cssClassDefinitions.push({ cssClassDefinitions.push({
scope: page.name,
name: object.name, name: object.name,
cssProps, cssProps,
}) })
@ -59,5 +58,10 @@ export const adaptPageComponentsToCssClassDefinitions = (
} }
} }
return cssClassDefinitions return {
scope: page.name,
pageComponents: cssClassDefinitions,
}
} }
export default adaptPageComponentsToCssClassDefinitions

View file

@ -0,0 +1,79 @@
import { CSSClassDefinition, FontsSummary, TypographyAssets } from '../../types'
import { PenpotApiTypography, CssTextProperty, PenpotApiFile } from '../types'
const mapTypographyAssetCssProps = (
typography: PenpotApiTypography,
): Record<CssTextProperty, string> => {
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<CSSClassDefinition>(
(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<string>()),
)
const fontsSummary = dedupedFontsKeys.reduce<FontsSummary>(
(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

View file

@ -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 * from './types'
export { Penpot as default } from './penpot' export { Penpot as default } from './penpot'

View file

@ -1,12 +1,11 @@
import path from 'node:path' import path from 'node:path'
import { adaptTypographiesToCssClassDefinitions } from './adapters/inbound/typographiesToCssClasses' import {
import { summarizeTypographies } from './adapters/inbound/typographiesToFontSummary' default as PenpotApiClient,
import { adaptColorsToCssVariables } from './adapters/inbound/colorsToCssVariables' adaptColors,
import { adaptPageComponentsToCssClassDefinitions } from './adapters/inbound/pageComponentsToCssClasses' adaptTypographies,
adaptPageComponents,
import { Penpot } from './api/penpot' } from './api'
import { import {
parseUserConfig, parseUserConfig,
normalizePenpotExportUserConfig, normalizePenpotExportUserConfig,
@ -20,17 +19,24 @@ import {
jsonOutputter, jsonOutputter,
OutputterFunction, OutputterFunction,
} from './outputters' } from './outputters'
import {
scopeTypographiesClassNames,
scopePageComponentsClassNames,
TransformerFunction,
} from './transformers'
import { PenpotExportAssets } from './types' import { PenpotExportAssets } from './types'
const processOutput = ({ function processOutput<T extends PenpotExportAssets>({
outputFormat = 'css', outputFormat,
outputPath, outputPath,
assets, assets,
transform,
}: { }: {
outputFormat: AssetConfig['format'] outputFormat: AssetConfig['format']
outputPath: string outputPath: string
assets: PenpotExportAssets assets: T
}) => { transform?: TransformerFunction<T>
}) {
const outputter: OutputterFunction | null = const outputter: OutputterFunction | null =
outputFormat === 'css' outputFormat === 'css'
? cssOutputter ? cssOutputter
@ -43,7 +49,8 @@ const processOutput = ({
if (outputter === null) if (outputter === null)
throw new PenpotExportInternalError('Unable to process output format') 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) return writeTextFile(outputPath, textContents)
} }
@ -54,7 +61,7 @@ export default async function penpotExport(
const parsedUserConfig = parseUserConfig(userConfig) const parsedUserConfig = parseUserConfig(userConfig)
const config = normalizePenpotExportUserConfig(parsedUserConfig) const config = normalizePenpotExportUserConfig(parsedUserConfig)
const penpot = new Penpot({ const penpot = new PenpotApiClient({
baseUrl: config.instance, baseUrl: config.instance,
accessToken: config.accessToken, accessToken: config.accessToken,
}) })
@ -70,9 +77,7 @@ export default async function penpotExport(
processOutput({ processOutput({
outputFormat: colorsConfig.format, outputFormat: colorsConfig.format,
outputPath: path.resolve(rootProjectPath, colorsConfig.output), outputPath: path.resolve(rootProjectPath, colorsConfig.output),
assets: { assets: adaptColors(penpotFile),
colors: adaptColorsToCssVariables(penpotFile),
},
}) })
console.log('✅ Colors: %s', colorsConfig.output) console.log('✅ Colors: %s', colorsConfig.output)
@ -82,10 +87,11 @@ export default async function penpotExport(
processOutput({ processOutput({
outputFormat: typographiesConfig.format, outputFormat: typographiesConfig.format,
outputPath: path.resolve(rootProjectPath, typographiesConfig.output), outputPath: path.resolve(rootProjectPath, typographiesConfig.output),
assets: { assets: adaptTypographies(penpotFile),
typographies: adaptTypographiesToCssClassDefinitions(penpotFile), transform:
typographiesSummary: summarizeTypographies(penpotFile), typographiesConfig.format === 'css'
}, ? scopeTypographiesClassNames
: undefined,
}) })
console.log('✅ Typographies: %s', typographiesConfig.output) console.log('✅ Typographies: %s', typographiesConfig.output)
@ -95,11 +101,13 @@ export default async function penpotExport(
processOutput({ processOutput({
outputFormat: pagesConfig.format, outputFormat: pagesConfig.format,
outputPath: path.resolve(rootProjectPath, pagesConfig.output), outputPath: path.resolve(rootProjectPath, pagesConfig.output),
assets: { assets: adaptPageComponents(penpotFile, {
pageComponents: adaptPageComponentsToCssClassDefinitions(penpotFile, { pageId: pagesConfig.pageId,
pageId: pagesConfig.pageId, }),
}), transform:
}, pagesConfig.format === 'css'
? scopePageComponentsClassNames
: undefined,
}) })
console.log('✅ Page components: %s', pagesConfig.output) console.log('✅ Page components: %s', pagesConfig.output)

View file

@ -18,9 +18,8 @@ import {
} from './syntax' } from './syntax'
const serializeCssClass = (cssClassDefinition: CSSClassDefinition): string => { const serializeCssClass = (cssClassDefinition: CSSClassDefinition): string => {
const selector = textToCssClassSelector( const selector = textToCssClassSelector(cssClassDefinition.name)
`${cssClassDefinition.scope}--${cssClassDefinition.name}`,
)
const cssValidProps = Object.keys(cssClassDefinition.cssProps).map( const cssValidProps = Object.keys(cssClassDefinition.cssProps).map(
(key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`, (key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`,
) )
@ -56,6 +55,7 @@ const composeFileHeader = (fontsSummary: FontsSummary) => {
} }
const serializeCss: OutputterFunction = ({ const serializeCss: OutputterFunction = ({
scope,
colors, colors,
typographies, typographies,
typographiesSummary, typographiesSummary,
@ -64,7 +64,7 @@ const serializeCss: OutputterFunction = ({
if (colors) { if (colors) {
return serializeCssCustomPropertiesRoot(colors) return serializeCssCustomPropertiesRoot(colors)
} else if (typographies) { } 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) const header: string = composeFileHeader(typographiesSummary)
return header + '\n\n' + body return header + '\n\n' + body

View file

@ -1,18 +1,66 @@
import { ColorAssets, PageComponentAssets, TypographyAssets } from '../types' import {
CSSClassDefinition,
CSSCustomPropertyDefinition,
ColorAssets,
PageComponentAssets,
TypographyAssets,
} from '../types'
import { PenpotExportInvalidAssetsError } from './errors' import { PenpotExportInvalidAssetsError } from './errors'
import { OutputterFunction } from './types' 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<JSONCustomPropertyDefinition>(
(customProperty) => {
return {
scope,
...customProperty,
}
},
)
}
const appendScopeToClasses = (
cssClassDefinition: CSSClassDefinition[],
scope: string,
): JSONClassDefinition[] => {
return cssClassDefinition.map<JSONClassDefinition>((classDefinition) => {
return {
scope,
...classDefinition,
}
})
}
const serializeJson: OutputterFunction = ({ const serializeJson: OutputterFunction = ({
scope,
colors, colors,
typographies, typographies,
pageComponents, pageComponents,
}: ColorAssets | TypographyAssets | PageComponentAssets): string => { }: 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() throw new PenpotExportInvalidAssetsError()
return JSON.stringify(assets, null, 2)
} }
export default serializeJson export default serializeJson

View file

@ -0,0 +1,2 @@
export * from './scopeClassNames'
export type { TransformerFunction } from './types'

View file

@ -0,0 +1,40 @@
import {
CSSClassDefinition,
PageComponentAssets,
TypographyAssets,
} from '../types'
const scopeClassNames = (
cssClassDefinition: CSSClassDefinition[],
scope: string,
): CSSClassDefinition[] => {
return cssClassDefinition.map<CSSClassDefinition>(({ 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,
}
}

View file

@ -0,0 +1,5 @@
import { ColorAssets, PageComponentAssets, TypographyAssets } from '../types'
export type TransformerFunction<
T extends ColorAssets | TypographyAssets | PageComponentAssets,
> = (assets: T) => T

View file

@ -1,11 +1,9 @@
export interface CSSClassDefinition { export interface CSSClassDefinition {
scope: string
name: string name: string
cssProps: Record<string, string> cssProps: Record<string, string>
} }
export interface CSSCustomPropertyDefinition { export interface CSSCustomPropertyDefinition {
scope: string
name: string name: string
value: string value: string
} }
@ -20,6 +18,7 @@ export interface FontsSummary {
} }
interface BaseAssets { interface BaseAssets {
scope: string
colors?: never colors?: never
typographies?: never typographies?: never
typographiesSummary?: never typographiesSummary?: never