0
Fork 0
mirror of https://github.com/penpot/penpot-export.git synced 2025-01-04 13:50:05 -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 } 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<CSSCustomPropertyDefinition>((color) => {
return {
scope: fileName,
name: color.name,
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 {
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

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

View file

@ -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<T extends PenpotExportAssets>({
outputFormat,
outputPath,
assets,
transform,
}: {
outputFormat: AssetConfig['format']
outputPath: string
assets: PenpotExportAssets
}) => {
assets: T
transform?: TransformerFunction<T>
}) {
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)

View file

@ -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

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 { 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 = ({
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

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 {
scope: string
name: string
cssProps: Record<string, string>
}
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