mirror of
https://github.com/penpot/penpot-export.git
synced 2025-01-06 14:50:10 -05:00
refactor(cli): extract adapters and a CSS outputter
This commit is contained in:
parent
66f0b48fa4
commit
7f43fc1d98
8 changed files with 160 additions and 130 deletions
|
@ -0,0 +1,23 @@
|
||||||
|
import { textToCssCustomProperyName } from '../../css/helpers'
|
||||||
|
|
||||||
|
import { CSSClassDefinition, PenpotExportFile } from '../../types'
|
||||||
|
|
||||||
|
export const adaptColorsToCssVariables = (
|
||||||
|
penpotFile: PenpotExportFile,
|
||||||
|
): CSSClassDefinition => {
|
||||||
|
const { colors } = penpotFile
|
||||||
|
|
||||||
|
const cssPropsEntries = Object.values(colors).map((color) => {
|
||||||
|
const objectClassName = textToCssCustomProperyName(color.name)
|
||||||
|
|
||||||
|
return [
|
||||||
|
objectClassName,
|
||||||
|
color.color, // FIXME Add opacity with rgba()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
selector: ':root',
|
||||||
|
cssProps: Object.fromEntries(cssPropsEntries),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
getObjectShapesFromPage,
|
||||||
|
isComponent,
|
||||||
|
pickObjectProps,
|
||||||
|
} from '../../api/helpers'
|
||||||
|
import { textToCssClassSelector } from '../../css/helpers'
|
||||||
|
|
||||||
|
import { PenpotApiObject } from '../../api'
|
||||||
|
import { CSSClassDefinition, PenpotExportFile } from '../../types'
|
||||||
|
|
||||||
|
const extractObjectCssProps = (object: PenpotApiObject) => {
|
||||||
|
let { textDecoration, ...styles } = object.positionData[0]
|
||||||
|
const isTextObject = object.type === 'text'
|
||||||
|
|
||||||
|
if (isTextObject) {
|
||||||
|
if (!textDecoration.startsWith('none')) {
|
||||||
|
styles = { ...styles, textDecoration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTextObjectCssProps = (object: PenpotApiObject) => {
|
||||||
|
const textCssProps = [
|
||||||
|
'fontStyle',
|
||||||
|
'fontSize',
|
||||||
|
'fontWeight',
|
||||||
|
'direction',
|
||||||
|
'fontFamily',
|
||||||
|
]
|
||||||
|
const objectCssProps = extractObjectCssProps(object)
|
||||||
|
|
||||||
|
return pickObjectProps(objectCssProps, textCssProps)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adaptPageComponentsToCssClassDefinitions = (
|
||||||
|
penpotFile: PenpotExportFile,
|
||||||
|
options: { pageId: string },
|
||||||
|
): CSSClassDefinition[] => {
|
||||||
|
const cssClassDefinitions = []
|
||||||
|
|
||||||
|
const page = penpotFile.pages[options.pageId]
|
||||||
|
|
||||||
|
const components = Object.values(page.objects)
|
||||||
|
.filter(isComponent)
|
||||||
|
.map((object) => getObjectShapesFromPage(object, page))
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
for (const objectId in component.objects) {
|
||||||
|
const object = component.objects[objectId]
|
||||||
|
if (object.type === 'text') {
|
||||||
|
const cssProps = getTextObjectCssProps(object)
|
||||||
|
const selector = textToCssClassSelector(`${page.name}--${object.name}`)
|
||||||
|
cssClassDefinitions.push({ selector, cssProps })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssClassDefinitions
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { textToCssClassSelector } from '../../css/helpers'
|
||||||
|
|
||||||
|
import { PenpotApiTypography, CssTextProperty } from '../../api'
|
||||||
|
import { CSSClassDefinition, PenpotExportFile } 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: PenpotExportFile,
|
||||||
|
): CSSClassDefinition[] => {
|
||||||
|
const { fileName, typographies } = penpotFile
|
||||||
|
|
||||||
|
const cssClassDefinitions = Object.values(typographies).map((typography) => {
|
||||||
|
const cssProps = getTypographyAssetCssProps(typography)
|
||||||
|
const selector = textToCssClassSelector(`${fileName}--${typography.name}`)
|
||||||
|
|
||||||
|
return { selector, cssProps }
|
||||||
|
})
|
||||||
|
|
||||||
|
return cssClassDefinitions
|
||||||
|
}
|
|
@ -1,10 +1,7 @@
|
||||||
import fetch, { RequestInit } from 'node-fetch'
|
import fetch, { RequestInit } from 'node-fetch'
|
||||||
import { pickObjectProps } from './helpers'
|
|
||||||
import type {
|
import type {
|
||||||
PenpotApiErrorResponse,
|
PenpotApiErrorResponse,
|
||||||
PenpotApiFile,
|
PenpotApiFile,
|
||||||
PenpotApiObject,
|
|
||||||
PenpotApiTypography,
|
|
||||||
PenpotClientFetcherOptions,
|
PenpotClientFetcherOptions,
|
||||||
PenpotClientGetFileOptions,
|
PenpotClientGetFileOptions,
|
||||||
PenpotClientSettings,
|
PenpotClientSettings,
|
||||||
|
@ -68,32 +65,6 @@ export class Penpot {
|
||||||
return json as ResultType
|
return json as ResultType
|
||||||
}
|
}
|
||||||
|
|
||||||
static extractObjectCssProps(object: PenpotApiObject) {
|
|
||||||
let { textDecoration, ...styles } = object.positionData[0]
|
|
||||||
const isTextObject = object.type === 'text'
|
|
||||||
|
|
||||||
if (isTextObject) {
|
|
||||||
if (!textDecoration.startsWith('none')) {
|
|
||||||
styles = { ...styles, textDecoration }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
static getTextObjectCssProps(object: PenpotApiObject) {
|
|
||||||
const textCssProps = [
|
|
||||||
'fontStyle',
|
|
||||||
'fontSize',
|
|
||||||
'fontWeight',
|
|
||||||
'direction',
|
|
||||||
'fontFamily',
|
|
||||||
]
|
|
||||||
const objectCssProps = Penpot.extractObjectCssProps(object)
|
|
||||||
|
|
||||||
return pickObjectProps(objectCssProps, textCssProps)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFile(
|
async getFile(
|
||||||
options: PenpotClientGetFileOptions,
|
options: PenpotClientGetFileOptions,
|
||||||
): Promise<PenpotExportFile> {
|
): Promise<PenpotExportFile> {
|
||||||
|
@ -111,21 +82,4 @@ export class Penpot {
|
||||||
pages: file.data.pagesIndex ?? {},
|
pages: file.data.pagesIndex ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getTypographyAssetCssProps(typography: PenpotApiTypography) {
|
|
||||||
const textCssProps = [
|
|
||||||
'lineHeight',
|
|
||||||
'fontStyle',
|
|
||||||
'textTransform',
|
|
||||||
'fontWeight',
|
|
||||||
'direction',
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
...pickObjectProps(typography, textCssProps),
|
|
||||||
fontSize: `${typography.fontSize}px`,
|
|
||||||
letterSpacing: `${typography.letterSpacing}px`,
|
|
||||||
fontFamily: `"${typography.fontFamily}"`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ export interface PenpotApiColor extends PenpotApiAsset {
|
||||||
opacity: number
|
opacity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type CssTextProperty =
|
export type CssTextProperty =
|
||||||
| 'lineHeight'
|
| 'lineHeight'
|
||||||
| 'fontStyle'
|
| 'fontStyle'
|
||||||
| 'textTransform'
|
| 'textTransform'
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
import fs from 'fs'
|
|
||||||
import { camelToKebab } from './string'
|
|
||||||
import { CSSClassDefinition } from './types'
|
|
||||||
|
|
||||||
/** From: https://www.w3.org/TR/css-syntax-3/#escaping
|
/** From: https://www.w3.org/TR/css-syntax-3/#escaping
|
||||||
* Any Unicode code point can be included in an ident sequence or quoted string by escaping it. CSS escape sequences
|
* Any Unicode code point can be included in an ident sequence or quoted string by escaping it. CSS escape sequences
|
||||||
* start with a backslash (\), and continue with:
|
* start with a backslash (\), and continue with:
|
||||||
|
@ -78,28 +74,3 @@ export function textToCssCustomProperyName(str: string) {
|
||||||
const unescapedDashedIdentifier = '--' + str.trimStart()
|
const unescapedDashedIdentifier = '--' + str.trimStart()
|
||||||
return textToCssIdentToken(unescapedDashedIdentifier)
|
return textToCssIdentToken(unescapedDashedIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cssClassDefinitionToCSS(
|
|
||||||
cssClassDefinition: CSSClassDefinition,
|
|
||||||
): string {
|
|
||||||
const cssValidProps = Object.keys(cssClassDefinition.cssProps).map(
|
|
||||||
(key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return [`${cssClassDefinition.selector} {`, ...cssValidProps, '}'].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeCssFile(
|
|
||||||
path: string,
|
|
||||||
cssClassDefinitions: CSSClassDefinition[],
|
|
||||||
) {
|
|
||||||
const css = cssClassDefinitions.map(cssClassDefinitionToCSS).join('\n\n')
|
|
||||||
const pathDirs = path.trim().split('/')
|
|
||||||
const dirname = pathDirs.slice(0, pathDirs.length - 1).join('/')
|
|
||||||
|
|
||||||
if (!fs.existsSync(dirname)) {
|
|
||||||
fs.mkdirSync(dirname, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(path, css, 'utf-8')
|
|
||||||
}
|
|
|
@ -1,13 +1,10 @@
|
||||||
|
import path from 'node:path'
|
||||||
import Penpot from '../lib/api'
|
import Penpot from '../lib/api'
|
||||||
import { validateUserConfig, normalizePenpotExportUserConfig } from './config'
|
import { validateUserConfig, normalizePenpotExportUserConfig } from './config'
|
||||||
import { CSSClassDefinition } from './types'
|
import { writeCssFile } from './outputters/css'
|
||||||
import {
|
import { adaptTypographiesToCssClassDefinitions } from './adapters/inbound/typographyToCssClasses'
|
||||||
textToCssClassSelector,
|
import { adaptColorsToCssVariables } from './adapters/inbound/colorsToCssVariables'
|
||||||
textToCssCustomProperyName,
|
import { adaptPageComponentsToCssClassDefinitions } from './adapters/inbound/pageComponentsToCssClasses'
|
||||||
writeCssFile,
|
|
||||||
} from './css'
|
|
||||||
import path from 'path'
|
|
||||||
import { getObjectShapesFromPage, isComponent } from './api/helpers'
|
|
||||||
|
|
||||||
export async function generateCssFromConfig(
|
export async function generateCssFromConfig(
|
||||||
userConfig: object,
|
userConfig: object,
|
||||||
|
@ -32,20 +29,8 @@ export async function generateCssFromConfig(
|
||||||
console.log('🎨 Processing Penpot file: %s', penpotFile.fileName)
|
console.log('🎨 Processing Penpot file: %s', penpotFile.fileName)
|
||||||
|
|
||||||
for (const colorsConfig of fileConfig.colors) {
|
for (const colorsConfig of fileConfig.colors) {
|
||||||
const cssClassDefinition: CSSClassDefinition = {
|
|
||||||
selector: ':root',
|
|
||||||
cssProps: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
const { colors } = penpotFile
|
|
||||||
|
|
||||||
for (const colorId in colors) {
|
|
||||||
const color = colors[colorId]
|
|
||||||
const objectClassname = textToCssCustomProperyName(color.name)
|
|
||||||
cssClassDefinition.cssProps[objectClassname] = color.color // FIXME Add opacity with rgba()
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssPath = path.resolve(rootProjectPath, colorsConfig.output)
|
const cssPath = path.resolve(rootProjectPath, colorsConfig.output)
|
||||||
|
const cssClassDefinition = adaptColorsToCssVariables(penpotFile)
|
||||||
|
|
||||||
writeCssFile(cssPath, [cssClassDefinition])
|
writeCssFile(cssPath, [cssClassDefinition])
|
||||||
|
|
||||||
|
@ -53,20 +38,9 @@ export async function generateCssFromConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const typographiesConfig of fileConfig.typographies) {
|
for (const typographiesConfig of fileConfig.typographies) {
|
||||||
const cssClassDefinitions: CSSClassDefinition[] = []
|
|
||||||
|
|
||||||
const { fileName, typographies } = penpotFile
|
|
||||||
|
|
||||||
for (const typographyId in typographies) {
|
|
||||||
const typography = typographies[typographyId]
|
|
||||||
const cssProps = Penpot.getTypographyAssetCssProps(typography)
|
|
||||||
const selector = textToCssClassSelector(
|
|
||||||
`${fileName}--${typography.name}`,
|
|
||||||
)
|
|
||||||
cssClassDefinitions.push({ selector, cssProps })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssPath = path.resolve(rootProjectPath, typographiesConfig.output)
|
const cssPath = path.resolve(rootProjectPath, typographiesConfig.output)
|
||||||
|
const cssClassDefinitions =
|
||||||
|
adaptTypographiesToCssClassDefinitions(penpotFile)
|
||||||
|
|
||||||
writeCssFile(cssPath, cssClassDefinitions)
|
writeCssFile(cssPath, cssClassDefinitions)
|
||||||
|
|
||||||
|
@ -74,26 +48,10 @@ export async function generateCssFromConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pagesConfig of fileConfig.pages) {
|
for (const pagesConfig of fileConfig.pages) {
|
||||||
const cssClassDefinitions: CSSClassDefinition[] = []
|
const cssClassDefinitions = adaptPageComponentsToCssClassDefinitions(
|
||||||
|
penpotFile,
|
||||||
const page = penpotFile.pages[pagesConfig.pageId]
|
{ pageId: pagesConfig.pageId },
|
||||||
|
)
|
||||||
const components = Object.values(page.objects)
|
|
||||||
.filter(isComponent)
|
|
||||||
.map((object) => getObjectShapesFromPage(object, page))
|
|
||||||
|
|
||||||
for (const component of components) {
|
|
||||||
for (const objectId in component.objects) {
|
|
||||||
const object = component.objects[objectId]
|
|
||||||
if (object.type === 'text') {
|
|
||||||
const cssProps = Penpot.getTextObjectCssProps(object)
|
|
||||||
const selector = textToCssClassSelector(
|
|
||||||
`${page.name}--${object.name}`,
|
|
||||||
)
|
|
||||||
cssClassDefinitions.push({ selector, cssProps })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssPath = path.resolve(rootProjectPath, pagesConfig.output)
|
const cssPath = path.resolve(rootProjectPath, pagesConfig.output)
|
||||||
|
|
||||||
|
|
30
packages/penpot-css-export/src/lib/outputters/css.ts
Normal file
30
packages/penpot-css-export/src/lib/outputters/css.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { camelToKebab } from '../string'
|
||||||
|
import { CSSClassDefinition } from '../types'
|
||||||
|
|
||||||
|
const serializeCssClass = (cssClassDefinition: CSSClassDefinition): string => {
|
||||||
|
const cssValidProps = Object.keys(cssClassDefinition.cssProps).map(
|
||||||
|
(key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [`${cssClassDefinition.selector} {`, ...cssValidProps, '}'].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeCss = (cssClassDefinitions: CSSClassDefinition[]): string => {
|
||||||
|
return cssClassDefinitions.map(serializeCssClass).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCssFile(
|
||||||
|
outputPath: string,
|
||||||
|
cssClassDefinitions: CSSClassDefinition[],
|
||||||
|
) {
|
||||||
|
const css = serializeCss(cssClassDefinitions)
|
||||||
|
const dirname = path.dirname(outputPath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(dirname)) {
|
||||||
|
fs.mkdirSync(dirname, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, css, 'utf-8')
|
||||||
|
}
|
Loading…
Reference in a new issue