0
Fork 0
mirror of https://github.com/penpot/penpot-export.git synced 2025-01-22 14:38:57 -05:00

feat(cli): redesign user config

This commit is contained in:
Roberto Redradix 2023-08-30 15:17:57 +02:00 committed by Roberto RedRadix
parent 06b8da67fe
commit ce380d99b7
5 changed files with 247 additions and 196 deletions

View file

@ -1,28 +1,33 @@
require('dotenv').config()
/**
* @type {import('penpot-css-export').Config}
* @type {import('penpot-css-export').UserConfig}
*/
const config = {
instance: process.env.PENPOT_BASE_URL || undefined,
accessToken: process.env.PENPOT_ACCESS_TOKEN,
colors: [
files: [
{
output: 'src/styles/colors.css', // 👈🏻 Path where your css should be generated.
fileId: '4a499800-872e-80e1-8002-fc0b585dc061'
},
],
typographies: [
{
output: 'src/styles/typographies.css', // 👈🏻 Path where your css should be generated.
fileId: '4a499800-872e-80e1-8002-fc0b585dc061'
},
],
pages: [
{
output: 'src/styles/ui.css', // 👈🏻 Path where your css should be generated.
fileId: 'abea3ef6-4c19-808a-8003-01370d9cb586',
pageId: '71b1702b-2eb1-81d6-8002-f82a5f182088',
pages: [
{
pageId: '71b1702b-2eb1-81d6-8002-f82a5f182088',
output: 'src/styles/ui.css', // 👈🏻 Path where your css should be generated.
},
],
},
{
fileId: '4a499800-872e-80e1-8002-fc0b585dc061',
colors: [
{
output: 'src/styles/colors.css', // 👈🏻 Path where your css should be generated.
},
],
typographies: [
{
output: 'src/styles/typographies.css', // 👈🏻 Path where your css should be generated.
},
],
},
],
}

View file

@ -118,18 +118,23 @@ export class Penpot {
return pickObjectProps(objectCssProps, textCssProps)
}
async getFileTypographies(
options: PenpotGetFileOptions,
): Promise<{ fileName: string; typographies: PenpotTypography[] }> {
async getFile(options: PenpotGetFileOptions): Promise<{
fileName: string
colors: PenpotColor[]
typographies: PenpotTypography[]
}> {
const file = await this.fetcher<PenpotFile>({
command: 'get-file',
body: {
id: options.fileId,
},
})
const typographies = Object.values(file.data.typographies)
const colors = file.data.colors ? Object.values(file.data.colors) : []
const typographies = file.data.typographies
? Object.values(file.data.typographies)
: []
return { fileName: file.name, typographies }
return { fileName: file.name, colors, typographies }
}
static getTypographyAssetCssProps(typography: PenpotTypography) {
@ -148,18 +153,4 @@ export class Penpot {
fontFamily: `"${typography.fontFamily}"`,
}
}
async getFileColors(
options: PenpotGetFileOptions,
): Promise<{ fileName: string; colors: PenpotColor[] }> {
const file = await this.fetcher<PenpotFile>({
command: 'get-file',
body: {
id: options.fileId,
},
})
const colors = Object.values(file.data.colors)
return { fileName: file.name, colors }
}
}

View file

@ -1,127 +1,158 @@
import { Config, PagesConfig } from './types'
import { Config, PagesConfig, FileConfig } from './types'
const BASE_CONFIG: Omit<Config, 'pages' | 'typographies' | 'colors'> = {
instance: 'https://design.penpot.app',
accessToken: '',
}
const PAGES_CONFIG: PagesConfig = {
output: '',
const createBaseFileConfig = (): FileConfig => ({
fileId: '',
pageId: '',
}
colors: [],
typographies: [],
pages: [],
})
class MissingAccessTokenError extends Error {
const createBasePagesConfig = (): PagesConfig => ({
output: '',
pageId: '',
})
class FileIdConfigError extends Error {
constructor() {
super('Missing or empty .accessToken in penpot export config.')
super(`Missing or invalid .fileId in penpot export config.`)
}
}
class InvalidInstanceUrlError extends Error {
class OutputPathConfigError extends Error {
constructor(index: number) {
super(`Missing or invalid .path in penpot export config at index ${index}.`)
}
}
class PageIdConfigError extends Error {
constructor(index: number) {
super(
`Missing or invalid .pageId in penpot export config at index ${index}.`,
)
}
}
function validateAndNormalizePenpotExportFileConfig(
userConfig: object,
): FileConfig {
const normalizedConfig = createBaseFileConfig()
if ('fileId' in userConfig && typeof userConfig.fileId === 'string') {
normalizedConfig.fileId = userConfig.fileId
} else throw new FileIdConfigError()
if ('colors' in userConfig && Array.isArray(userConfig.colors)) {
normalizedConfig.colors = userConfig.colors.map(
(colorsConfig: object, index) => {
if (
'output' in colorsConfig &&
typeof colorsConfig.output === 'string'
) {
return {
output: colorsConfig.output,
}
} else throw new OutputPathConfigError(index)
},
)
}
if ('typographies' in userConfig && Array.isArray(userConfig.typographies)) {
normalizedConfig.typographies = userConfig.typographies.map(
(typographiesConfig: object, index) => {
if (
'output' in typographiesConfig &&
typeof typographiesConfig.output === 'string'
) {
return {
output: typographiesConfig.output,
}
} else throw new OutputPathConfigError(index)
},
)
}
if ('pages' in userConfig && Array.isArray(userConfig.pages)) {
normalizedConfig.pages = userConfig.pages.map(
(pageConfig: object, index) => {
const normalizedPageConfig = createBasePagesConfig()
if ('output' in pageConfig && typeof pageConfig.output === 'string') {
normalizedPageConfig.output = pageConfig.output
} else throw new OutputPathConfigError(index)
if ('pageId' in pageConfig && typeof pageConfig.pageId === 'string') {
normalizedPageConfig.pageId = pageConfig.pageId
} else throw new PageIdConfigError(index)
return normalizedPageConfig
},
)
}
return normalizedConfig
}
const createBaseConfig = (): Config => ({
instance: 'https://design.penpot.app',
accessToken: '',
files: [],
})
class AccessTokenConfigError extends Error {
constructor() {
super('Missing or invalid .accessToken in penpot export config.')
}
}
class InvalidInstanceUrlConfigError extends Error {
constructor() {
super('Invalid .instance URL in penpot export config.')
}
}
class MissingOutputPathError extends Error {
constructor(index: number) {
super(`Missing or empty .path in penpot export config at index ${index}.`)
class FilesConfigError extends Error {
constructor() {
super('Missing or invalid .files in penpot export config.')
}
}
class MissingFileIdError extends Error {
constructor(index: number) {
super(`Missing or empty .fileId in penpot export config at index ${index}.`)
}
}
export function validateAndNormalizePenpotExportConfig(
userConfig: object,
): Config {
let normalizedConfig: Config = createBaseConfig()
class MissingPageIdError extends Error {
constructor(index: number) {
super(`Missing or empty .pageId in penpot export config at index ${index}.`)
}
}
if (
'accessToken' in userConfig &&
typeof userConfig.accessToken === 'string'
) {
normalizedConfig.accessToken = userConfig.accessToken
} else throw new AccessTokenConfigError()
export function validateAndNormalizePenpotExportConfig(config: Config): Config {
if (!config.accessToken) {
throw new MissingAccessTokenError()
}
let normalizedConfig: Config = {
...BASE_CONFIG,
...config,
pages: [],
typographies: [],
colors: [],
}
if (config.instance != null) {
try {
new URL(config.instance)
} catch (error) {
if (error instanceof TypeError) {
throw new InvalidInstanceUrlError()
if ('instance' in userConfig) {
if (typeof userConfig.instance === 'string') {
try {
new URL(userConfig.instance)
} catch (error) {
if (error instanceof TypeError)
throw new InvalidInstanceUrlConfigError()
throw error
}
throw error
}
normalizedConfig.instance = config.instance.endsWith('/')
? config.instance.slice(0, -1)
: config.instance
normalizedConfig.instance = userConfig.instance.endsWith('/')
? userConfig.instance.slice(0, -1)
: userConfig.instance
} else throw new InvalidInstanceUrlConfigError()
}
for (const [index, colorsConfig] of config.colors.entries()) {
const { output, fileId } = colorsConfig
if (!output) {
throw new MissingOutputPathError(index)
}
if (!fileId) {
throw new MissingFileIdError(index)
}
normalizedConfig.colors.push({
output,
fileId,
})
}
for (const [index, typographiesConfig] of config.typographies.entries()) {
const { output, fileId } = typographiesConfig
if (!output) {
throw new MissingOutputPathError(index)
}
if (!fileId) {
throw new MissingFileIdError(index)
}
normalizedConfig.typographies.push({
output,
fileId,
})
}
for (const [index, pageConfig] of config.pages.entries()) {
if (!pageConfig.output) {
throw new MissingOutputPathError(index)
}
if (!pageConfig.fileId) {
throw new MissingFileIdError(index)
}
if (!pageConfig.pageId) {
throw new MissingPageIdError(index)
}
normalizedConfig.pages.push({
...PAGES_CONFIG,
...pageConfig,
})
}
if (
'files' in userConfig &&
Array.isArray(userConfig.files) &&
userConfig.files.length > 0
) {
normalizedConfig.files = userConfig.files.map(
validateAndNormalizePenpotExportFileConfig,
)
} else throw new FilesConfigError()
return normalizedConfig
}

View file

@ -9,79 +9,87 @@ import {
import path from 'path'
export async function generateCssFromConfig(
config: Config,
userConfig: object,
rootProjectPath: string,
) {
const validatedConfig = validateAndNormalizePenpotExportConfig(config)
const config = validateAndNormalizePenpotExportConfig(userConfig)
const penpot = new Penpot({
baseUrl: validatedConfig.instance,
accessToken: validatedConfig.accessToken,
baseUrl: config.instance,
accessToken: config.accessToken,
})
for (const colorsConfig of validatedConfig.colors) {
const cssClassDefinition: CSSClassDefinition = {
selector: ':root',
cssProps: {},
}
const { colors } = await penpot.getFileColors({
fileId: colorsConfig.fileId,
for (const fileConfig of config.files) {
const penpotFile = await penpot.getFile({
fileId: fileConfig.fileId,
})
for (const color of colors) {
const objectClassname = textToCssCustomProperyName(color.name)
cssClassDefinition.cssProps[objectClassname] = color.color // FIXME Add opacity with rgba()
console.log('🖼️ Processing Penpot file: %s', penpotFile.fileName)
for (const colorsConfig of fileConfig.colors) {
const cssClassDefinition: CSSClassDefinition = {
selector: ':root',
cssProps: {},
}
const { colors } = penpotFile
for (const color of colors) {
const objectClassname = textToCssCustomProperyName(color.name)
cssClassDefinition.cssProps[objectClassname] = color.color // FIXME Add opacity with rgba()
}
const cssPath = path.resolve(rootProjectPath, colorsConfig.output)
writeCssFile(cssPath, [cssClassDefinition])
console.log('✅ Colors: %s', colorsConfig.output)
}
const cssPath = path.resolve(rootProjectPath, colorsConfig.output)
for (const typographiesConfig of fileConfig.typographies) {
const cssClassDefinitions: CSSClassDefinition[] = []
writeCssFile(cssPath, [cssClassDefinition])
const { fileName, typographies } = penpotFile
console.log('✅ Colors: %s', colorsConfig.output)
}
for (const typography of typographies) {
const cssProps = Penpot.getTypographyAssetCssProps(typography)
const selector = textToCssClassSelector(
`${fileName}--${typography.name}`,
)
cssClassDefinitions.push({ selector, cssProps })
}
for (const typographiesConfig of validatedConfig.typographies) {
const cssClassDefinitions: CSSClassDefinition[] = []
const cssPath = path.resolve(rootProjectPath, typographiesConfig.output)
const { fileName, typographies } = await penpot.getFileTypographies({
fileId: typographiesConfig.fileId,
})
writeCssFile(cssPath, cssClassDefinitions)
for (const typography of typographies) {
const cssProps = Penpot.getTypographyAssetCssProps(typography)
const selector = textToCssClassSelector(`${fileName}--${typography.name}`)
cssClassDefinitions.push({ selector, cssProps })
console.log('✅ Typographies: %s', typographiesConfig.output)
}
const cssPath = path.resolve(rootProjectPath, typographiesConfig.output)
for (const pagesConfig of fileConfig.pages) {
const cssClassDefinitions: CSSClassDefinition[] = []
writeCssFile(cssPath, cssClassDefinitions)
const { pageName, components } = await penpot.getPageComponents({
fileId: fileConfig.fileId,
pageId: pagesConfig.pageId,
})
console.log('✅ Typographies: %s', typographiesConfig.output)
}
for (const page of validatedConfig.pages) {
const cssClassDefinitions: CSSClassDefinition[] = []
const { pageName, components } = await penpot.getPageComponents({
fileId: page.fileId,
pageId: page.pageId,
})
for (const component of components) {
for (const object of component.objects) {
if (object.type === 'text') {
const cssProps = Penpot.getTextObjectCssProps(object)
const selector = textToCssClassSelector(`${pageName}--${object.name}`)
cssClassDefinitions.push({ selector, cssProps })
for (const component of components) {
for (const object of component.objects) {
if (object.type === 'text') {
const cssProps = Penpot.getTextObjectCssProps(object)
const selector = textToCssClassSelector(
`${pageName}--${object.name}`,
)
cssClassDefinitions.push({ selector, cssProps })
}
}
}
const cssPath = path.resolve(rootProjectPath, pagesConfig.output)
writeCssFile(cssPath, cssClassDefinitions)
console.log('✅ Page components: %s', pagesConfig.output)
}
const cssPath = path.resolve(rootProjectPath, page.output)
writeCssFile(cssPath, cssClassDefinitions)
console.log('✅ Page components: %s', page.output)
}
}

View file

@ -1,25 +1,41 @@
export interface PagesConfig {
output: string
fileId: string
pageId: string
}
export interface TypographiesConfig {
output: string
fileId: string
}
export interface ColorsConfig {
output: string
}
export type FileUserConfig = {
fileId: string
} & (
| { colors: ColorsConfig[] }
| { typographies: TypographiesConfig[] }
| { pages: PagesConfig[] }
)
export interface UserConfig {
instance?: string
accessToken: string
files: FileUserConfig[]
}
export interface FileConfig {
fileId: string
colors: ColorsConfig[]
typographies: TypographiesConfig[]
pages: PagesConfig[]
}
export interface Config {
instance: string
accessToken: string
colors: ColorsConfig[]
typographies: TypographiesConfig[]
pages: PagesConfig[]
files: FileConfig[]
}
export interface CSSClassDefinition {