diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 74c74e7..07ad1a2 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -8,12 +8,13 @@ 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', @@ -21,6 +22,7 @@ beforeAll(() => { codegen: { on: mock(() => {}) }, closePlugin: mock(() => {}), } as unknown as typeof figma + codeModule = await import('../code-impl') }) beforeEach(() => { @@ -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': @@ -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), @@ -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)) +}) diff --git a/src/code-impl.ts b/src/code-impl.ts new file mode 100644 index 0000000..92d86fa --- /dev/null +++ b/src/code-impl.ts @@ -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) + } +} diff --git a/src/code.ts b/src/code.ts index 6a6cf50..b573b4e 100644 --- a/src/code.ts +++ b/src/code.ts @@ -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) diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index c39732f..61856c1 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -9,6 +9,7 @@ import { getDevupComponentByNode, getDevupComponentByProps, } from './utils/get-devup-component' +import { buildCssUrl } from './utils/wrap-url' export class Codegen { components: Map< @@ -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 diff --git a/src/codegen/utils/__tests__/wrap-url.test.ts b/src/codegen/utils/__tests__/wrap-url.test.ts new file mode 100644 index 0000000..7bcad19 --- /dev/null +++ b/src/codegen/utils/__tests__/wrap-url.test.ts @@ -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')", + ) + }) +}) diff --git a/src/codegen/utils/paint-to-css.ts b/src/codegen/utils/paint-to-css.ts index 8160280..7ba4edf 100644 --- a/src/codegen/utils/paint-to-css.ts +++ b/src/codegen/utils/paint-to-css.ts @@ -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 @@ -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` } } @@ -195,7 +196,8 @@ async function convertPattern(fill: PatternPaint): Promise { 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( diff --git a/src/codegen/utils/wrap-url.ts b/src/codegen/utils/wrap-url.ts new file mode 100644 index 0000000..a77b74a --- /dev/null +++ b/src/codegen/utils/wrap-url.ts @@ -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})` +}