Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions src/__tests__/code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ import {
mock,
spyOn,
} from 'bun:test'
import { registerCodegen, run, runCommand } from '../code'
import * as devupModule from '../commands/devup'
import * as exportAssetsModule from '../commands/exportAssets'
import * as exportComponentsModule from '../commands/exportComponents'

beforeAll(() => {
let codeModule: typeof import('../code-impl')

beforeAll(async () => {
;(globalThis as { figma?: unknown }).figma = {
editorType: 'dev',
mode: 'codegen',
command: 'noop',
codegen: { on: mock(() => {}) },
closePlugin: mock(() => {}),
} as unknown as typeof figma
codeModule = await import('../code-impl')
})

beforeEach(() => {
Expand Down Expand Up @@ -61,7 +63,7 @@ describe('runCommand', () => {
closePlugin,
} as unknown as typeof figma

await runCommand(figmaMock as typeof figma)
await codeModule.runCommand(figmaMock as typeof figma)

switch (fn) {
case 'exportDevup':
Expand Down Expand Up @@ -131,7 +133,7 @@ describe('registerCodegen', () => {
codegen: { on: mock(() => {}) },
closePlugin: mock(() => {}),
} as unknown as typeof figma
registerCodegen(figmaMock)
codeModule.registerCodegen(figmaMock)
expect(figmaMock.codegen.on).toHaveBeenCalledWith(
'generate',
expect.any(Function),
Expand All @@ -145,23 +147,38 @@ describe('registerCodegen', () => {
})
})

it('should not register codegen if figma is not defined', () => {
run(undefined as unknown as typeof figma)
it('should not register codegen if figma is not defined', async () => {
codeModule.run(undefined as unknown as typeof figma)
expect(devupModule.exportDevup).not.toHaveBeenCalled()
expect(devupModule.importDevup).not.toHaveBeenCalled()
expect(exportAssetsModule.exportAssets).not.toHaveBeenCalled()
expect(exportComponentsModule.exportComponents).not.toHaveBeenCalled()
})

it('should run command', () => {
it('should run command', async () => {
const figmaMock = {
editorType: 'figma',
command: 'export-devup',
closePlugin: mock(() => {}),
} as unknown as typeof figma
run(figmaMock as typeof figma)
codeModule.run(figmaMock as typeof figma)
expect(devupModule.exportDevup).toHaveBeenCalledWith('json')
expect(devupModule.importDevup).not.toHaveBeenCalled()
expect(exportAssetsModule.exportAssets).not.toHaveBeenCalled()
expect(exportComponentsModule.exportComponents).not.toHaveBeenCalled()
})

it('auto-runs on module load when figma is present', async () => {
const codegenOn = mock(() => {})
;(globalThis as { figma?: unknown }).figma = {
editorType: 'dev',
mode: 'codegen',
command: 'noop',
codegen: { on: codegenOn },
closePlugin: mock(() => {}),
} as unknown as typeof figma

await import(`../code?with-figma=${Date.now()}`)

expect(codegenOn).toHaveBeenCalledWith('generate', expect.any(Function))
})
96 changes: 96 additions & 0 deletions src/code-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Codegen } from './codegen/Codegen'
import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
import { exportComponents } from './commands/exportComponents'

export function registerCodegen(ctx: typeof figma) {
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
ctx.codegen.on('generate', async ({ node, language, ...rest }) => {
console.info(rest, node)
switch (language) {
case 'devup-ui': {
const time = Date.now()
const codegen = new Codegen(node)
await codegen.run()
const componentsCodes = codegen.getComponentsCodes()
console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`)
return [
...(node.type === 'COMPONENT' ||
node.type === 'COMPONENT_SET' ||
node.type === 'INSTANCE'
? []
: [
{
title: node.name,
language: 'TYPESCRIPT',
code: codegen.getCode(),
} as const,
]),
...(componentsCodes.length > 0
? ([
{
title: `${node.name} - Components`,
language: 'TYPESCRIPT',
code: componentsCodes.map((code) => code[1]).join('\n\n'),
},
{
title: `${node.name} - Components CLI`,
language: 'BASH',
code: componentsCodes
.map(
([componentName, code]) =>
`echo '${code}' > ${componentName}.tsx`,
)
.join('\n'),
},
] as const)
: []),
]
}
}
return []
})
}
}

export function runCommand(ctx: typeof figma = figma) {
switch (ctx.command) {
case 'export-devup':
exportDevup('json').finally(() => ctx.closePlugin())
break
case 'export-devup-without-treeshaking':
exportDevup('json', false).finally(() => ctx.closePlugin())
break
case 'export-devup-excel':
exportDevup('excel').finally(() => ctx.closePlugin())
break
case 'export-devup-excel-without-treeshaking':
exportDevup('excel', false).finally(() => ctx.closePlugin())
break
case 'import-devup':
importDevup('json').finally(() => ctx.closePlugin())
break
case 'import-devup-excel':
importDevup('excel').finally(() => ctx.closePlugin())
break
case 'export-assets':
exportAssets().finally(() => ctx.closePlugin())
break
case 'export-components':
exportComponents().finally(() => ctx.closePlugin())
break
}
}

export function run(ctx: typeof figma) {
if (typeof ctx !== 'undefined') {
registerCodegen(ctx)
runCommand(ctx)
}
}

export function autoRun(ctx: typeof figma | undefined = figma) {
if (typeof ctx !== 'undefined') {
run(ctx)
}
}
92 changes: 2 additions & 90 deletions src/code.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,3 @@
import { Codegen } from './codegen/Codegen'
import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
import { exportComponents } from './commands/exportComponents'
import { autoRun } from './code-impl'

export function registerCodegen(ctx: typeof figma = figma) {
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
ctx.codegen.on('generate', async ({ node, language }) => {
switch (language) {
case 'devup-ui': {
const time = Date.now()
const codegen = new Codegen(node)
await codegen.run()
const componentsCodes = codegen.getComponentsCodes()
console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`)
return [
...(node.type === 'COMPONENT' ||
node.type === 'COMPONENT_SET' ||
node.type === 'INSTANCE'
? []
: [
{
title: node.name,
language: 'TYPESCRIPT',
code: codegen.getCode(),
} as const,
]),
...(componentsCodes.length > 0
? ([
{
title: `${node.name} - Components`,
language: 'TYPESCRIPT',
code: componentsCodes.map((code) => code[1]).join('\n\n'),
},
{
title: `${node.name} - Components CLI`,
language: 'BASH',
code: componentsCodes
.map(
([componentName, code]) =>
`echo '${code}' > ${componentName}.tsx`,
)
.join('\n'),
},
] as const)
: []),
]
}
}
return []
})
}
}

export function runCommand(ctx: typeof figma = figma) {
switch (ctx.command) {
case 'export-devup':
exportDevup('json').finally(() => ctx.closePlugin())
break
case 'export-devup-without-treeshaking':
exportDevup('json', false).finally(() => ctx.closePlugin())
break
case 'export-devup-excel':
exportDevup('excel').finally(() => ctx.closePlugin())
break
case 'export-devup-excel-without-treeshaking':
exportDevup('excel', false).finally(() => ctx.closePlugin())
break
case 'import-devup':
importDevup('json').finally(() => ctx.closePlugin())
break
case 'import-devup-excel':
importDevup('excel').finally(() => ctx.closePlugin())
break
case 'export-assets':
exportAssets().finally(() => ctx.closePlugin())
break
case 'export-components':
exportComponents().finally(() => ctx.closePlugin())
break
}
}

export function run(ctx: typeof figma) {
if (typeof ctx !== 'undefined') {
registerCodegen(ctx)
runCommand(ctx)
}
}

run((globalThis as { figma?: unknown }).figma as typeof figma)
autoRun(typeof figma === 'undefined' ? undefined : figma)
3 changes: 2 additions & 1 deletion src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getDevupComponentByNode,
getDevupComponentByProps,
} from './utils/get-devup-component'
import { buildCssUrl } from './utils/wrap-url'

export class Codegen {
components: Map<
Expand Down Expand Up @@ -80,7 +81,7 @@ export class Codegen {
const maskColor = await checkSameColor(node)
if (maskColor) {
// support mask image icon
props.maskImage = `url(${props.src})`
props.maskImage = buildCssUrl(props.src)
props.maskRepeat = 'no-repeat'
props.maskSize = 'contain'
props.bg = maskColor
Expand Down
20 changes: 20 additions & 0 deletions src/codegen/utils/__tests__/wrap-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from 'bun:test'
import { buildCssUrl } from '../wrap-url'

describe('buildCssUrl', () => {
test('keeps simple paths unquoted', () => {
expect(buildCssUrl('/icons/logo.svg')).toBe('url(/icons/logo.svg)')
})

test('wraps paths with spaces', () => {
expect(buildCssUrl('/icons/logo icon.svg')).toBe(
"url('/icons/logo icon.svg')",
)
})

test('escapes single quotes inside path', () => {
expect(buildCssUrl("/icons/John's icon.svg")).toBe(
"url('/icons/John\\'s icon.svg')",
)
})
})
12 changes: 7 additions & 5 deletions src/codegen/utils/paint-to-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { rgbaToHex } from '../../utils/rgba-to-hex'
import { checkAssetNode } from './check-asset-node'
import { fmtPct } from './fmtPct'
import { solidToString } from './solid-to-string'
import { buildCssUrl } from './wrap-url'

interface Point {
x: number
Expand Down Expand Up @@ -48,13 +49,13 @@ function convertImage(fill: ImagePaint): string {

switch (fill.scaleMode) {
case 'FILL':
return `url(/icons/${imageName}) center/cover no-repeat`
return `${buildCssUrl(`/icons/${imageName}`)} center/cover no-repeat`
case 'FIT':
return `url(/icons/${imageName}) center/contain no-repeat`
return `${buildCssUrl(`/icons/${imageName}`)} center/contain no-repeat`
case 'CROP':
return `url(/icons/${imageName}) center/cover no-repeat`
return `${buildCssUrl(`/icons/${imageName}`)} center/cover no-repeat`
case 'TILE':
return `url(/icons/${imageName}) repeat`
return `${buildCssUrl(`/icons/${imageName}`)} repeat`
}
}

Expand Down Expand Up @@ -195,7 +196,8 @@ async function convertPattern(fill: PatternPaint): Promise<string> {
const position = [horizontalPosition, verticalPosition]
.filter(Boolean)
.join(' ')
return `url(/icons/${imageName}.${imageExtension})${position ? ` ${position}` : ''} repeat`
const url = buildCssUrl(`/icons/${imageName}.${imageExtension}`)
return `${url}${position ? ` ${position}` : ''} repeat`
}

function convertPosition(
Expand Down
11 changes: 11 additions & 0 deletions src/codegen/utils/wrap-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Build a CSS url() value. If the path contains whitespace or characters
* that commonly require quoting, wrap it in single quotes and escape any
* existing single quotes.
*/
export function buildCssUrl(path: string): string {
const normalized = path.trim()
const needsQuotes = /[\s'"()]/.test(normalized)
const escaped = normalized.replace(/'/g, "\\'")
return `url(${needsQuotes ? `'${escaped}'` : escaped})`
}