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:
parent
07977124da
commit
660f2b9ae9
13 changed files with 245 additions and 124 deletions
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
79
packages/core/src/lib/api/adapters/typographies.ts
Normal file
79
packages/core/src/lib/api/adapters/typographies.ts
Normal 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
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
2
packages/core/src/lib/transformers/index.ts
Normal file
2
packages/core/src/lib/transformers/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './scopeClassNames'
|
||||
export type { TransformerFunction } from './types'
|
40
packages/core/src/lib/transformers/scopeClassNames.ts
Normal file
40
packages/core/src/lib/transformers/scopeClassNames.ts
Normal 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,
|
||||
}
|
||||
}
|
5
packages/core/src/lib/transformers/types.ts
Normal file
5
packages/core/src/lib/transformers/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { ColorAssets, PageComponentAssets, TypographyAssets } from '../types'
|
||||
|
||||
export type TransformerFunction<
|
||||
T extends ColorAssets | TypographyAssets | PageComponentAssets,
|
||||
> = (assets: T) => T
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue