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:
parent
06b8da67fe
commit
ce380d99b7
5 changed files with 247 additions and 196 deletions
|
@ -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.
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue